mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34b41967d | ||
|
|
c063dbd76e | ||
|
|
d1f8db93d6 | ||
|
|
926939f24b | ||
|
|
48d70c092c | ||
|
|
c14b319212 | ||
|
|
55b22bfb9d | ||
|
|
48b212c9d2 | ||
|
|
074f21ba58 | ||
|
|
9241d767ee | ||
|
|
65996ade59 | ||
|
|
09f5bb7816 | ||
|
|
12a5985a58 | ||
|
|
d23632931b | ||
|
|
92e7692779 | ||
|
|
0c11623666 | ||
|
|
b454d100c6 | ||
|
|
097473d371 | ||
|
|
5e3157b18d | ||
|
|
d9c5f7a0d2 | ||
|
|
6456e24bb5 | ||
|
|
0b19154bd3 | ||
|
|
ecc6cd9f6b | ||
|
|
71a21b5c6f | ||
|
|
79b1b4d5b5 | ||
|
|
85b38de4a7 | ||
|
|
686bf91bb5 | ||
|
|
f07f8210c3 | ||
|
|
4153d23652 | ||
|
|
760bab0893 | ||
|
|
ce8ddb48bb | ||
|
|
377cbe0aec | ||
|
|
a4c10887cf | ||
|
|
3fcab4d8d9 | ||
|
|
f46d9706eb | ||
|
|
9ecbf0ada5 | ||
|
|
82746ed592 | ||
|
|
da0eeae6a7 | ||
|
|
262f9a7f8d | ||
|
|
ca732d57e7 | ||
|
|
f357e0fe26 | ||
|
|
976aeb4848 | ||
|
|
2bea69c7d9 | ||
|
|
0bb815ca22 | ||
|
|
aa188751e4 | ||
|
|
ba44f7d928 | ||
|
|
8a95787426 | ||
|
|
aebe6a256b | ||
|
|
791b832f25 | ||
|
|
580ce2277d | ||
|
|
8a556c5c63 | ||
|
|
520a3f912c | ||
|
|
6f0eea48fa | ||
|
|
b01010f2d1 | ||
|
|
fd443e3a2f | ||
|
|
8d85a9d778 | ||
|
|
237528b385 | ||
|
|
492a2966c0 | ||
|
|
5fa6b064da | ||
|
|
df652c5735 | ||
|
|
d0c5c7fb97 | ||
|
|
6ab355245f | ||
|
|
272215c551 | ||
|
|
721308cb8e | ||
|
|
cc58ab1754 | ||
|
|
193b571967 | ||
|
|
bac2e39ee5 | ||
|
|
9825db82aa | ||
|
|
985d00f0a4 | ||
|
|
dbc5789406 | ||
|
|
eb3b84216a | ||
|
|
a07a7aebf2 | ||
|
|
37ee573014 | ||
|
|
b34b7dd2e6 | ||
|
|
dd0f49fa3d | ||
|
|
7d3284cf28 | ||
|
|
117a7823d6 | ||
|
|
7db4606038 | ||
|
|
3f083f57de | ||
|
|
326edc4e83 | ||
|
|
1e90497760 |
@@ -0,0 +1,35 @@
|
||||
name: Update README version
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Update version in README
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
FILE="README.md"
|
||||
|
||||
# Update cargo dependency version (e.g. "0.2" -> "0.3")
|
||||
MAJOR_MINOR="${VERSION#v}"
|
||||
MAJOR_MINOR="${MAJOR_MINOR%.*}"
|
||||
sed -i "s|libfreemkv = \"[0-9]*\.[0-9]*\"|libfreemkv = \"${MAJOR_MINOR}\"|" "$FILE"
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet || git commit -m "Update to libfreemkv ${VERSION}"
|
||||
git push
|
||||
@@ -3,3 +3,4 @@ Cargo.lock
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.cargo/
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
## 0.6.0 (2026-04-10)
|
||||
|
||||
### API improvements
|
||||
|
||||
- **`open()` works on all drives** — no profile match required. Unknown drives can scan, read BD/DVD at OEM speed. `init()` is optional and adds features (riplock removal, UHD reads, speed control).
|
||||
- **`has_profile()`** — check if unlock parameters are available for this drive
|
||||
- **`find_drives()`** — returns all optical drives, not just profile-matched ones
|
||||
- **`raw_gc_010c`** on `DriveId` — raw GET_CONFIG 010C response bytes for profile sharing
|
||||
|
||||
### AACS 2.0
|
||||
|
||||
- **SCSI handshake wired end-to-end** — ECDH key agreement, real Volume ID from drive, read data key for bus decryption
|
||||
- **Bus decryption active** — UHD discs with bus encryption now decrypted transparently
|
||||
- **VUK derivation from Media Key + VID** — works for discs not in KEYDB (processing key + device key paths)
|
||||
|
||||
### MKV muxer
|
||||
|
||||
- **15 new files** — EBML writer, TS demuxer, stream assembly pipeline
|
||||
- **Codec parsers** — H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1
|
||||
- **`MkvStream`** — builder pattern, wraps any `impl Write`, configurable lookahead buffer
|
||||
|
||||
### Cleanup
|
||||
|
||||
- Removed orphaned `jar.rs` (342 lines) — replaced by `labels/` module
|
||||
- Error refactor: 40+ sites converted from English strings to typed error codes
|
||||
|
||||
## 0.5.0 (2026-04-09)
|
||||
|
||||
### Read pipeline — 5x speed improvement
|
||||
|
||||
- **Kernel transfer limit detection**: auto-detect `max_hw_sectors_kb` via sysfs, resolve sg→block device. Previously hardcoded to 510 sectors (1MB) which exceeded the 120KB kernel limit, causing all reads to error and fall back to 6KB reads at 4.8 MB/s. Now auto-tunes to 48 sectors (96KB) or whatever the device supports.
|
||||
- **Result: 12.5 MB/s sustained, 23 MB/s peak** (was 4.8 MB/s)
|
||||
|
||||
### LibreDrive — full init pipeline
|
||||
|
||||
- **All 10 ARM handlers translated**: unlock, firmware upload (A: WRITE_BUFFER, B: MODE SELECT), calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
|
||||
- **Cold boot firmware upload**: WRITE_BUFFER 1888B (A variant) or MODE SELECT 2496B (B variant) proven on hardware
|
||||
- **Speed calibration**: 256+ disc surface probes, 64-entry speed table, triple SET_CD_SPEED
|
||||
- **Platform trait locked down**: `pub(crate)`, 3 methods only (init, set_read_speed, is_ready)
|
||||
- **Init guard**: prevents double-init, signature mismatch aborts early
|
||||
|
||||
### MPLS parser fixes
|
||||
|
||||
- **PGS in audio slots**: subtitle language read at correct offset (was truncated: "ng " → "eng")
|
||||
- **Secondary PG entries**: n_pip_pg loop added for correct STN position tracking
|
||||
- **Secondary stream types**: stream_type 5 (sec audio), 6 (sec video), 7 (DV EL) attribute parsing
|
||||
- **Empty stream filter**: coding_type 0x00 entries (padding) no longer appear as "Unknown(0)"
|
||||
|
||||
### Profiles
|
||||
|
||||
- **206 profiles with full per-drive data**: ld_microcode (base64), all CDBs, speed tables, signatures
|
||||
- **Automated pipeline**: `sdf_unpack --profiles` → profiles.json (no manual merging)
|
||||
|
||||
## 0.4.0 (2026-04-07)
|
||||
|
||||
### Labels — complete rewrite
|
||||
|
||||
- **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)
|
||||
+11
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.2.1"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
@@ -12,17 +12,21 @@ categories = ["hardware-support", "multimedia"]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "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"] }
|
||||
base64 = "0.22.1"
|
||||
|
||||
[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"
|
||||
|
||||
@@ -1,147 +1,109 @@
|
||||
[](https://crates.io/crates/libfreemkv)
|
||||
[](https://docs.rs/libfreemkv)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
|
||||
# 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, stream labels, AACS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
|
||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
|
||||
Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
## Install
|
||||
|
||||
### As a library
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.2"
|
||||
libfreemkv = "0.6"
|
||||
```
|
||||
|
||||
### Prebuilt binaries
|
||||
|
||||
Download from [GitHub Releases](https://github.com/freemkv/libfreemkv/releases):
|
||||
|
||||
| Platform | Architecture | Download |
|
||||
|----------|-------------|----------|
|
||||
| Linux | x86_64 | [freemkv-info](https://github.com/freemkv/libfreemkv/releases/latest) |
|
||||
| Linux | aarch64 | [freemkv-info](https://github.com/freemkv/libfreemkv/releases/latest) |
|
||||
|
||||
## Usage
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use libfreemkv::DriveSession;
|
||||
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.wait_ready()?; // wait for disc
|
||||
session.init()?; // unlock + firmware upload
|
||||
session.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
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
|
||||
// Open and auto-identify
|
||||
let mut session = DriveSession::open(device_path)?;
|
||||
|
||||
// Drive identity
|
||||
session.drive_id.vendor_id // "HL-DT-ST"
|
||||
session.drive_id.product_id // "BD-RE BU40N"
|
||||
session.drive_id.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()?; // activate raw mode
|
||||
session.calibrate()?; // speed optimization
|
||||
session.read_sectors(lba, count, &mut buf)?;
|
||||
session.status()?; // feature flags
|
||||
session.read_config()?; // drive configuration
|
||||
session.read_register(index)?; // hardware registers
|
||||
```
|
||||
|
||||
## Chipset Support
|
||||
|
||||
| Chipset | Status | Drives | Brands |
|
||||
|---------|--------|--------|--------|
|
||||
| MediaTek MT1959 | Supported | 206 | LG, ASUS, HP |
|
||||
| Renesas | Planned | -- | Pioneer |
|
||||
|
||||
Each profile stores per-drive `unlock_mode` and `unlock_buf_id` — the exact CDB bytes for that drive's unlock command. A single `Mt1959` implementation handles all MediaTek variants.
|
||||
|
||||
## Drive Profile
|
||||
|
||||
Each bundled profile (compiled into the binary):
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
## What It Does
|
||||
|
||||
Structured errors for programmatic handling — no user-facing English text in the library.
|
||||
- **Drive access** — open, identify, unlock, firmware upload, speed calibration, eject
|
||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
|
||||
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
|
||||
- **Content reading** — adaptive batch reads with automatic decryption and error recovery
|
||||
|
||||
| Code | Error | Meaning |
|
||||
|------|-------|---------|
|
||||
| E1000 | DeviceNotFound | Device path doesn't exist |
|
||||
| E1001 | DevicePermission | No access (try sudo or cdrom group) |
|
||||
| E2000 | UnsupportedDrive | No matching profile |
|
||||
| E2001 | ProfileNotFound | Profile lookup failed |
|
||||
| E3000 | UnlockFailed | Unlock command rejected |
|
||||
| E3001 | SignatureMismatch | Response signature wrong |
|
||||
| E3002 | NotUnlocked | Operation requires unlock first |
|
||||
| E4000 | ScsiError | SCSI command failed |
|
||||
| E5000 | IoError | System I/O error |
|
||||
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
|
||||
|
||||
```
|
||||
DriveSession
|
||||
├── ScsiTransport SG_IO (Linux) / IOKit (macOS, planned)
|
||||
├── DriveProfile per-drive parameters (bundled JSON, compiled in)
|
||||
├── DriveId INQUIRY + GET_CONFIG 010C fields
|
||||
└── Platform
|
||||
├── Mt1959 MediaTek MT1959 (206 drives)
|
||||
└── (Renesas) Pioneer (planned)
|
||||
```text
|
||||
DriveSession — open any drive, identify, init (optional), read sectors
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||
└── PlatformDriver — 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
|
||||
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||
├── AACS — key resolution + content decryption
|
||||
└── KEYDB — download + verify + save
|
||||
```
|
||||
|
||||
## Platform
|
||||
See [docs/](docs/) for detailed technical documentation on each module.
|
||||
|
||||
Linux only today (SG_IO ioctl). The `ScsiTransport` trait abstracts the platform — macOS IOKit and Windows SPTI backends are planned.
|
||||
## 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 |
|
||||
| E8xxx | KEYDB update errors |
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Status | Backend |
|
||||
|----------|--------|---------|
|
||||
| Linux | Supported | SG_IO ioctl |
|
||||
| macOS | Supported | IOKit SCSITask |
|
||||
| Windows | Planned | SPTI |
|
||||
|
||||
## Contributing
|
||||
|
||||
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to submit your drive's profile.
|
||||
Run `freemkv drive-info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fn main() {
|
||||
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target == "macos" {
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,203 @@
|
||||
# libfreemkv API Design
|
||||
|
||||
## Principles
|
||||
|
||||
1. Lib provides building blocks. App composes them.
|
||||
2. No English text in lib. Error codes only. App handles i18n.
|
||||
3. No display logic in lib. App decides what to show.
|
||||
4. Streams are the pipeline. Each stage wraps the next.
|
||||
5. Lib fires events. App listens.
|
||||
|
||||
## Core API
|
||||
|
||||
```rust
|
||||
// Open drive — explicit steps, app prints between them
|
||||
let mut session = DriveSession::open(path)?;
|
||||
session.wait_ready()?;
|
||||
session.init()?;
|
||||
session.probe_disc()?;
|
||||
|
||||
// Scan disc
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
|
||||
// Browse
|
||||
disc.titles // Vec<Title>
|
||||
disc.format // BD / UHD / DVD
|
||||
disc.capacity_gb()
|
||||
|
||||
// Rip with events
|
||||
disc.rip(&mut session, 0, output, |event| {
|
||||
match event.kind {
|
||||
EventKind::BytesRead { bytes, total } => ...,
|
||||
EventKind::ReadError { sector, error } => ...,
|
||||
EventKind::Retry { attempt } => ...,
|
||||
EventKind::SpeedChange { speed_kbs } => ...,
|
||||
EventKind::Complete { bytes, errors } => ...,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Rip without events
|
||||
disc.rip(&mut session, 0, output, event::ignore)?;
|
||||
```
|
||||
|
||||
## Stream Chains
|
||||
|
||||
Each stream wraps the next. Builder pattern, no `.build()`.
|
||||
|
||||
### Raw m2ts
|
||||
```rust
|
||||
disc.rip(&mut session, 0, File::create("movie.m2ts")?, event::ignore)?;
|
||||
```
|
||||
|
||||
### MKV
|
||||
```rust
|
||||
let output = MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(10 * 1024 * 1024);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
### MKV with progress (CLI)
|
||||
```rust
|
||||
let output = ProgressStream::new(
|
||||
MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(10 * 1024 * 1024),
|
||||
total_bytes,
|
||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
||||
);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
### Future: transcode
|
||||
```rust
|
||||
let output = ProgressStream::new(
|
||||
TranscodeStream::new(
|
||||
MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(50 * 1024 * 1024),
|
||||
)
|
||||
.codec(H265)
|
||||
.quality(22),
|
||||
total_bytes,
|
||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
||||
);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
Lib fires events during operations. App provides a callback. No display, no text.
|
||||
|
||||
```rust
|
||||
pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
pub enum EventKind {
|
||||
BytesRead { bytes: u64, total: u64 },
|
||||
ReadError { sector: u64, error: Error },
|
||||
Retry { attempt: u32 },
|
||||
SpeedChange { speed_kbs: u16 },
|
||||
ExtentStart { index: usize, start_sector: u64, sector_count: u64 },
|
||||
Complete { bytes: u64, errors: u32 },
|
||||
}
|
||||
```
|
||||
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI prints a line. Server logs to file.
|
||||
|
||||
## Error Codes
|
||||
|
||||
Lib errors are codes, not messages. Like HTTP status codes.
|
||||
|
||||
```rust
|
||||
pub enum Error {
|
||||
// Drive
|
||||
DriveNotFound,
|
||||
DriveOpenFailed,
|
||||
DriveNotReady,
|
||||
|
||||
// Unlock
|
||||
UnlockFailed,
|
||||
NoProfile,
|
||||
|
||||
// AACS
|
||||
AacsNoKeys,
|
||||
AacsCertVerifyFailed,
|
||||
AacsAgidAllocFailed,
|
||||
AacsHandshakeFailed,
|
||||
AacsVidMacFailed,
|
||||
|
||||
// Disc
|
||||
DiscReadError { sector: u64 },
|
||||
MplsParseError,
|
||||
ClpiParseError,
|
||||
UdfFileNotFound { path: String },
|
||||
|
||||
// Mux
|
||||
LookaheadOverflow,
|
||||
MuxWriteError,
|
||||
|
||||
// SCSI
|
||||
ScsiError { sense: u8 },
|
||||
}
|
||||
```
|
||||
|
||||
App maps codes to localized strings. Lib never contains display text.
|
||||
|
||||
## Streams the Lib Provides
|
||||
|
||||
| Stream | Purpose |
|
||||
|--------|---------|
|
||||
| MkvStream | BD-TS → MKV (demux + mux) |
|
||||
|
||||
CLI provides:
|
||||
| Stream | Purpose |
|
||||
|--------|---------|
|
||||
| ProgressStream | Byte counting + progress callback |
|
||||
| Future: TranscodeStream | Re-encode video |
|
||||
|
||||
Any `impl Write` works as a stream. Third-party apps create their own.
|
||||
|
||||
## MkvStream Internals
|
||||
|
||||
LookaheadBuffer (default 5MB, configurable):
|
||||
1. Phase 1: buffer incoming data, scan for codec setup (SPS/PPS)
|
||||
2. Found it? Write MKV header, flush buffer, switch to streaming
|
||||
3. Buffer full? Error — app handles it
|
||||
4. Phase 2: parse TS → frames → MKV clusters, direct to output
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
libfreemkv/src/
|
||||
├── lib.rs Public exports
|
||||
├── error.rs Error codes (no English)
|
||||
├── event.rs Event types for callbacks
|
||||
├── drive.rs DriveSession (open, init, read)
|
||||
├── disc.rs Disc (scan, rip, titles)
|
||||
├── scsi/ SCSI transport (Linux, macOS)
|
||||
├── platform/ Drive unlock (MT1959 A/B)
|
||||
├── aacs/ AACS decryption
|
||||
├── udf.rs UDF filesystem parser
|
||||
├── mpls.rs Playlist parser
|
||||
├── clpi.rs Clip info parser
|
||||
├── mux/
|
||||
│ ├── stream.rs MkvStream (builder pattern, impl Write)
|
||||
│ ├── lookahead.rs LookaheadBuffer (generic, reusable)
|
||||
│ ├── ts.rs BD-TS demuxer
|
||||
│ ├── ebml.rs EBML primitives
|
||||
│ ├── mkv.rs MKV muxer
|
||||
│ └── codec/ Frame parsers (H.264, HEVC, VC-1, AC3, DTS, TrueHD, PGS)
|
||||
└── ...
|
||||
|
||||
freemkv/src/
|
||||
├── main.rs CLI entry, command routing
|
||||
├── rip.rs Rip command (streams + progress)
|
||||
├── remux.rs Remux command (m2ts → MKV, no drive)
|
||||
├── info.rs Drive info display
|
||||
└── disc_info.rs Disc info display
|
||||
```
|
||||
@@ -0,0 +1,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
@@ -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
|
||||
@@ -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) |
|
||||
@@ -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) |
|
||||
@@ -0,0 +1,240 @@
|
||||
# MKV Native Muxer — Architecture
|
||||
|
||||
## Goal
|
||||
|
||||
Replace raw m2ts output with in-pipeline MKV muxing.
|
||||
Disc → TS demux → MKV mux → .mkv file. One pass, no temp files.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
ContentReader::read_batch() returns &[u8] of raw BD transport stream
|
||||
↓
|
||||
TsDemuxer::feed(batch) parses 192-byte BD-TS packets, extracts PES
|
||||
↓
|
||||
PES reassembly per PID builds complete PES packets with PTS/DTS
|
||||
↓
|
||||
ElementaryStreamParser per track finds frame boundaries, extracts codec headers
|
||||
↓
|
||||
MkvMuxer::write_frame(track, pts, data) writes EBML clusters + blocks
|
||||
↓
|
||||
.mkv file on disk
|
||||
```
|
||||
|
||||
## Current Integration Point
|
||||
|
||||
```rust
|
||||
// rip.rs line ~287
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
writer.write_all(batch)?; // ← replace with muxer.feed(batch)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Becomes:
|
||||
```rust
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
muxer.feed(batch)?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. BD Transport Stream Demuxer (`ts.rs`)
|
||||
|
||||
BD uses 192-byte packets (not standard 188):
|
||||
```
|
||||
[0-3] TP_extra_header: 2-bit copy_permission + 30-bit arrival_time_stamp
|
||||
[4] Sync byte: 0x47
|
||||
[5] TEI + PUSI + priority + PID[12:8]
|
||||
[6] PID[7:0]
|
||||
[7] Scrambling + adaptation + continuity_counter
|
||||
[8..] Adaptation field (if present) + payload
|
||||
```
|
||||
|
||||
API:
|
||||
```rust
|
||||
pub struct TsDemuxer {
|
||||
pes_assemblers: HashMap<u16, PesAssembler>, // PID → assembler
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
pub fn new(pids: &[u16]) -> Self;
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket>;
|
||||
}
|
||||
|
||||
pub struct PesPacket {
|
||||
pub pid: u16,
|
||||
pub pts: Option<i64>, // 90kHz ticks
|
||||
pub dts: Option<i64>, // 90kHz ticks
|
||||
pub data: Vec<u8>, // elementary stream data
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Elementary Stream Parsers (`codec/`)
|
||||
|
||||
Each codec parser finds frame boundaries and extracts initialization data.
|
||||
|
||||
**H.264 (`codec/h264.rs`):**
|
||||
- Parse NAL units (start code 00 00 01 or 00 00 00 01)
|
||||
- Extract SPS + PPS for codecPrivate
|
||||
- Frame boundary = Access Unit Delimiter (NAL type 9) or SPS
|
||||
|
||||
**HEVC (`codec/hevc.rs`):**
|
||||
- Parse NAL units
|
||||
- Extract VPS + SPS + PPS for codecPrivate
|
||||
- Frame boundary = VCL NAL with first_slice_segment_in_pic_flag
|
||||
|
||||
**AC3/EAC3 (`codec/ac3.rs`):**
|
||||
- Syncword 0x0B77
|
||||
- Parse frame size from header
|
||||
- No codecPrivate needed (or minimal)
|
||||
|
||||
**DTS (`codec/dts.rs`):**
|
||||
- Syncword 0x7FFE8001
|
||||
- Parse frame size
|
||||
- No codecPrivate needed
|
||||
|
||||
**TrueHD (`codec/truehd.rs`):**
|
||||
- Major sync: 0xF8726FBA
|
||||
- Access unit = major sync + minor syncs
|
||||
- AC3 core embedded in first substream
|
||||
|
||||
**LPCM (`codec/lpcm.rs`):**
|
||||
- Fixed frame sizes based on sample rate + channels
|
||||
- Header describes format
|
||||
|
||||
**PGS (`codec/pgs.rs`):**
|
||||
- Segment types: PCS, WDS, PDS, ODS, END
|
||||
- Each segment is a complete unit
|
||||
- No codecPrivate needed
|
||||
|
||||
### 3. MKV/EBML Muxer (`mkv.rs`)
|
||||
|
||||
Matroska uses EBML (Extensible Binary Meta Language).
|
||||
|
||||
**EBML primitives:**
|
||||
- Variable-length element ID (1-4 bytes)
|
||||
- Variable-length size (1-8 bytes)
|
||||
- Data: uint, int, float, string, UTF-8, binary, date
|
||||
|
||||
**MKV structure:**
|
||||
```
|
||||
EBML Header
|
||||
Segment
|
||||
├── SeekHead (index of top-level elements)
|
||||
├── Info (title, duration, muxing app)
|
||||
├── Tracks (one entry per stream)
|
||||
│ ├── TrackEntry (video)
|
||||
│ │ ├── CodecID: "V_MPEG4/ISO/AVC" or "V_MPEGH/ISO/HEVC"
|
||||
│ │ ├── CodecPrivate: SPS+PPS (H.264) or VPS+SPS+PPS (HEVC)
|
||||
│ │ └── Video: PixelWidth, PixelHeight, DisplayWidth, DisplayHeight
|
||||
│ ├── TrackEntry (audio)
|
||||
│ │ ├── CodecID: "A_AC3" or "A_TRUEHD" or "A_DTS"
|
||||
│ │ └── Audio: SamplingFrequency, Channels, BitDepth
|
||||
│ └── TrackEntry (subtitle)
|
||||
│ └── CodecID: "S_HDMV/PGS"
|
||||
├── Chapters (optional, from MPLS chapter marks)
|
||||
├── Cluster (every ~5 seconds)
|
||||
│ ├── Timestamp (cluster base time)
|
||||
│ ├── SimpleBlock (track, relative_ts, data)
|
||||
│ ├── SimpleBlock ...
|
||||
│ └── ...
|
||||
├── Cluster ...
|
||||
├── Cues (seek index, written at end)
|
||||
└── Tags (metadata)
|
||||
```
|
||||
|
||||
**API:**
|
||||
```rust
|
||||
pub struct MkvMuxer<W: Write + Seek> {
|
||||
writer: W,
|
||||
tracks: Vec<MkvTrack>,
|
||||
cluster_start: Option<i64>,
|
||||
cue_points: Vec<CuePoint>,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
pub fn new(writer: W, tracks: &[MkvTrack]) -> Result<Self>;
|
||||
pub fn write_frame(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Result<()>;
|
||||
pub fn finish(self) -> Result<()>; // writes Cues + fixes SeekHead
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Pipeline Glue (`mux.rs`)
|
||||
|
||||
Ties everything together:
|
||||
|
||||
```rust
|
||||
pub struct MuxPipeline<W: Write + Seek> {
|
||||
demuxer: TsDemuxer,
|
||||
parsers: HashMap<u16, Box<dyn CodecParser>>,
|
||||
muxer: MkvMuxer<W>,
|
||||
pid_to_track: HashMap<u16, usize>,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MuxPipeline<W> {
|
||||
pub fn new(writer: W, streams: &[Stream]) -> Result<Self>;
|
||||
pub fn feed(&mut self, ts_data: &[u8]) -> Result<()>;
|
||||
pub fn finish(self) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
libfreemkv/src/
|
||||
├── mux/
|
||||
│ ├── mod.rs MuxPipeline (glue)
|
||||
│ ├── ts.rs BD-TS demuxer (192-byte packets)
|
||||
│ ├── ebml.rs EBML primitives (write variable-length ints)
|
||||
│ ├── mkv.rs MKV muxer (Segment, Tracks, Clusters)
|
||||
│ └── codec/
|
||||
│ ├── mod.rs CodecParser trait
|
||||
│ ├── h264.rs H.264 NAL parser
|
||||
│ ├── hevc.rs HEVC NAL parser
|
||||
│ ├── ac3.rs AC3/EAC3 frame parser
|
||||
│ ├── dts.rs DTS frame parser
|
||||
│ ├── truehd.rs TrueHD/Atmos parser
|
||||
│ ├── lpcm.rs LPCM frame parser
|
||||
│ └── pgs.rs PGS subtitle parser
|
||||
```
|
||||
|
||||
## MKV Codec IDs
|
||||
|
||||
| Our Codec | MKV CodecID | codecPrivate |
|
||||
|-----------|------------|--------------|
|
||||
| H264 | V_MPEG4/ISO/AVC | AVCDecoderConfigurationRecord (SPS+PPS) |
|
||||
| Hevc | V_MPEGH/ISO/HEVC | HEVCDecoderConfigurationRecord (VPS+SPS+PPS) |
|
||||
| Vc1 | V_MS/VFW/FOURCC | BITMAPINFOHEADER |
|
||||
| Mpeg2 | V_MPEG2 | sequence_header |
|
||||
| Ac3 | A_AC3 | none |
|
||||
| Ac3Plus | A_EAC3 | none |
|
||||
| TrueHd | A_TRUEHD | none |
|
||||
| DtsHdMa | A_DTS | none (core + extension) |
|
||||
| Dts | A_DTS | none |
|
||||
| Lpcm | A_PCM/INT/BIG | none |
|
||||
| Pgs | S_HDMV/PGS | none |
|
||||
|
||||
## Timestamps
|
||||
|
||||
BD uses 90kHz PTS/DTS. MKV uses nanoseconds.
|
||||
Conversion: `ns = pts * 1_000_000_000 / 90_000` = `pts * 100_000 / 9`
|
||||
|
||||
MKV TimestampScale default = 1,000,000 (1ms precision).
|
||||
For BD content, 1ms is sufficient.
|
||||
|
||||
## Build Order
|
||||
|
||||
1. `ebml.rs` — EBML write primitives (smallest, no dependencies)
|
||||
2. `ts.rs` — BD-TS demuxer (parse 192-byte packets, PES assembly)
|
||||
3. `codec/ac3.rs` — simplest codec parser (fixed syncword)
|
||||
4. `mkv.rs` — MKV muxer (header, tracks, clusters, blocks)
|
||||
5. `mux.rs` — pipeline glue
|
||||
6. Test with AC3-only stream (simplest case)
|
||||
7. `codec/h264.rs` — video parser (NAL units, SPS/PPS)
|
||||
8. Full BD rip test (video + audio + subs)
|
||||
9. Remaining codecs (HEVC, DTS, TrueHD, PGS, LPCM, VC-1)
|
||||
+217
@@ -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
@@ -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
|
||||
+2067
-3093
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1482
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
//! CLPI clip info parser — maps clips to sector ranges on disc.
|
||||
//!
|
||||
//! Each .clpi file in BDMV/CLIPINF/ describes one M2TS clip.
|
||||
//! The EP (Entry Point) map provides timestamp → SPN mapping.
|
||||
//! SPN × 192 = byte offset in the m2ts file.
|
||||
//!
|
||||
//! Reference: https://github.com/lw/BluRay/wiki/CLPI
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::disc::Extent;
|
||||
|
||||
/// Parsed CLPI clip info.
|
||||
#[derive(Debug)]
|
||||
pub struct ClipInfo {
|
||||
pub version: String,
|
||||
/// Total source packets in the m2ts (each 192 bytes)
|
||||
pub source_packet_count: u32,
|
||||
/// Coarse EP entries for the primary video stream
|
||||
pub ep_coarse: Vec<EpCoarse>,
|
||||
/// Fine EP entries for the primary video stream
|
||||
pub ep_fine: Vec<EpFine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpCoarse {
|
||||
pub ref_to_fine_id: u32,
|
||||
pub pts_coarse: u32,
|
||||
pub spn_coarse: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpFine {
|
||||
pub pts_fine: u32,
|
||||
pub spn_fine: u32,
|
||||
}
|
||||
|
||||
impl ClipInfo {
|
||||
/// Reconstruct full PTS from coarse + fine entry.
|
||||
pub fn full_pts(coarse: &EpCoarse, fine: &EpFine) -> u32 {
|
||||
(coarse.pts_coarse << 19) + (fine.pts_fine << 8)
|
||||
}
|
||||
|
||||
/// Reconstruct full SPN from coarse + fine entry.
|
||||
pub fn full_spn(coarse: &EpCoarse, fine: &EpFine) -> u32 {
|
||||
(coarse.spn_coarse & 0xFFFE0000) + fine.spn_fine
|
||||
}
|
||||
|
||||
/// Get all EP entries as (PTS, SPN) pairs, fully resolved.
|
||||
pub fn resolved_ep_map(&self) -> Vec<(u32, u32)> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for (ci, coarse) in self.ep_coarse.iter().enumerate() {
|
||||
let fine_start = coarse.ref_to_fine_id as usize;
|
||||
let fine_end = if ci + 1 < self.ep_coarse.len() {
|
||||
self.ep_coarse[ci + 1].ref_to_fine_id as usize
|
||||
} else {
|
||||
self.ep_fine.len()
|
||||
};
|
||||
|
||||
for fi in fine_start..fine_end.min(self.ep_fine.len()) {
|
||||
let fine = &self.ep_fine[fi];
|
||||
let pts = Self::full_pts(coarse, fine);
|
||||
let spn = Self::full_spn(coarse, fine);
|
||||
entries.push((pts, spn));
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// Get sector extents for a given in/out time range.
|
||||
///
|
||||
/// Converts PTS timestamps to SPN ranges, then SPN to LBA
|
||||
/// using the file's starting LBA on disc.
|
||||
pub fn get_extents(&self, in_time: u32, out_time: u32) -> Vec<Extent> {
|
||||
let ep_map = self.resolved_ep_map();
|
||||
if ep_map.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Find SPN at or before in_time
|
||||
let start_spn = match ep_map.binary_search_by_key(&in_time, |(pts, _)| *pts) {
|
||||
Ok(i) => ep_map[i].1,
|
||||
Err(0) => ep_map[0].1,
|
||||
Err(i) => ep_map[i - 1].1,
|
||||
};
|
||||
|
||||
// Find SPN at or after out_time
|
||||
let end_spn = match ep_map.binary_search_by_key(&out_time, |(pts, _)| *pts) {
|
||||
Ok(i) => ep_map[i].1,
|
||||
Err(i) if i < ep_map.len() => ep_map[i].1,
|
||||
_ => ep_map.last().unwrap().1 + 1,
|
||||
};
|
||||
|
||||
if end_spn <= start_spn {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// SPN → byte offset: spn × 192
|
||||
// Byte offset → sectors: offset / 2048
|
||||
// Note: the caller needs to add the file's starting LBA from UDF
|
||||
let start_byte = start_spn as u64 * 192;
|
||||
let end_byte = end_spn as u64 * 192;
|
||||
let start_sector = (start_byte / 2048) as u32;
|
||||
let end_sector = ((end_byte + 2047) / 2048) as u32;
|
||||
|
||||
vec![Extent {
|
||||
start_lba: start_sector, // relative to m2ts file start
|
||||
sector_count: end_sector - start_sector,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CLPI file from raw bytes.
|
||||
pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
if data.len() < 40 {
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
|
||||
if &data[0..4] != b"HDMV" {
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
|
||||
// Header offsets
|
||||
let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
let _prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
let cpi_start = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
|
||||
|
||||
// ClipInfo section at offset 40
|
||||
// source_packet_count at offset 40 + 4(len) + 2(reserved) + 1(stream_type) + 1(app_type) + 4(reserved) + 4(ts_rate)
|
||||
let source_packet_count = if data.len() > 56 {
|
||||
u32::from_be_bytes([data[56], data[57], data[58], data[59]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Parse CPI / EP Map
|
||||
let (ep_coarse, ep_fine) = if cpi_start > 0 && cpi_start + 8 < data.len() {
|
||||
parse_cpi(&data[cpi_start..])?
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
Ok(ClipInfo {
|
||||
version,
|
||||
source_packet_count,
|
||||
ep_coarse,
|
||||
ep_fine,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the CPI section containing the EP map.
|
||||
fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
if data.len() < 8 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
let cpi_length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if cpi_length < 4 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// CPI type at bits 44-47 (byte 5, lower 4 bits)
|
||||
// Skip to EP map: offset 4 (after length) + 2 (reserved/type)
|
||||
let ep_map = &data[6..];
|
||||
if ep_map.len() < 4 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// EP map header
|
||||
// [0] reserved
|
||||
// [1] number of stream PID entries
|
||||
let num_streams = ep_map[1] as usize;
|
||||
if num_streams == 0 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Stream PID entry headers start at offset 2
|
||||
// Each: 2(PID) + 2(reserved+type) + 2(num_coarse) + 4(num_fine) + 4(ep_map_start) = 14 bytes
|
||||
// We only care about the first stream (primary video)
|
||||
if ep_map.len() < 16 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// 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]]);
|
||||
|
||||
// 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() {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
let stream_ep = &ep_map[ep_map_offset..];
|
||||
if stream_ep.len() < 4 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Fine table start address (relative to this stream EP map)
|
||||
let fine_start = u32::from_be_bytes([stream_ep[0], stream_ep[1], stream_ep[2], stream_ep[3]]) as usize;
|
||||
|
||||
// Coarse entries start at offset 4, 8 bytes each
|
||||
let coarse_data = &stream_ep[4..];
|
||||
let mut ep_coarse = Vec::with_capacity(num_coarse);
|
||||
for i in 0..num_coarse {
|
||||
let off = i * 8;
|
||||
if off + 8 > coarse_data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let dword0 = u32::from_be_bytes([coarse_data[off], coarse_data[off + 1],
|
||||
coarse_data[off + 2], coarse_data[off + 3]]);
|
||||
let ref_to_fine_id = dword0 >> 14;
|
||||
let pts_coarse = dword0 & 0x3FFF;
|
||||
let spn_coarse = u32::from_be_bytes([coarse_data[off + 4], coarse_data[off + 5],
|
||||
coarse_data[off + 6], coarse_data[off + 7]]);
|
||||
|
||||
ep_coarse.push(EpCoarse {
|
||||
ref_to_fine_id,
|
||||
pts_coarse,
|
||||
spn_coarse,
|
||||
});
|
||||
}
|
||||
|
||||
// Fine entries at fine_start, 4 bytes each
|
||||
let mut ep_fine = Vec::with_capacity(num_fine);
|
||||
if fine_start < stream_ep.len() {
|
||||
let fine_data = &stream_ep[fine_start..];
|
||||
for i in 0..num_fine {
|
||||
let off = i * 4;
|
||||
if off + 4 > fine_data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let dword = u32::from_be_bytes([fine_data[off], fine_data[off + 1],
|
||||
fine_data[off + 2], fine_data[off + 3]]);
|
||||
// Bits: is_angle(1) + i_end_offset(3) + pts_fine(11) + spn_fine(17)
|
||||
let pts_fine = (dword >> 17) & 0x7FF;
|
||||
let spn_fine = dword & 0x1FFFF;
|
||||
|
||||
ep_fine.push(EpFine { pts_fine, spn_fine });
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ep_coarse, ep_fine))
|
||||
}
|
||||
+1172
File diff suppressed because it is too large
Load Diff
+176
-81
@@ -1,131 +1,226 @@
|
||||
//! High-level drive session — the main API for consumers.
|
||||
//! Drive session — open, identify, 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.
|
||||
//! Three-step open:
|
||||
//! 1. `open()` — open device, identify drive. Always OEM.
|
||||
//! 2. `wait_ready()` — wait for disc to spin up. Call before reading.
|
||||
//! 3. `init()` — activate custom firmware. Removes riplock.
|
||||
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
|
||||
|
||||
use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::identity::DriveId;
|
||||
use crate::profile::{self, DriveProfile, Chipset};
|
||||
use crate::platform::{Platform, DriveStatus};
|
||||
use crate::profile::{self, DriveProfile};
|
||||
use crate::platform::PlatformDriver;
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
|
||||
/// A complete drive session.
|
||||
///
|
||||
/// Handles: identify → match profile → create platform → execute commands.
|
||||
pub struct DriveSession {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
platform: Box<dyn Platform>,
|
||||
pub profile: DriveProfile,
|
||||
driver: Option<Box<dyn PlatformDriver>>,
|
||||
pub profile: Option<DriveProfile>,
|
||||
pub platform: Option<profile::Platform>,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl DriveSession {
|
||||
/// Open a drive, identify it, and find the matching profile.
|
||||
/// Uses the bundled profile database — no external files needed.
|
||||
pub fn open(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 {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
product_revision: drive_id.product_revision.trim().to_string(),
|
||||
})?;
|
||||
|
||||
let platform: 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 m = profile::find_by_drive_id(&profiles, &drive_id);
|
||||
let (driver, platform, profile) = match m {
|
||||
Some(m) => (
|
||||
create_driver(m.platform, &m.profile).ok(),
|
||||
Some(m.platform),
|
||||
Some(m.profile),
|
||||
),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
driver,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
device_path: device.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open with an explicit profile (skip 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())?;
|
||||
/// Whether this drive has a known profile (unlock parameters available).
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
}
|
||||
|
||||
let platform: Box<dyn Platform> = match profile.chipset {
|
||||
Chipset::MediaTek => {
|
||||
Box::new(Mt1959::new(profile.clone()))
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
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(());
|
||||
}
|
||||
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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
Err(Error::DeviceNotFound {
|
||||
path: format!("{}: drive not ready after 30s", self.device_path),
|
||||
})
|
||||
}
|
||||
|
||||
/// Activate raw disc access mode.
|
||||
pub fn unlock(&mut self) -> Result<()> {
|
||||
self.platform.unlock(self.scsi.as_mut())
|
||||
pub fn platform_name(&self) -> &str {
|
||||
match self.platform {
|
||||
Some(ref p) => p.name(),
|
||||
None => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if raw disc access mode is enabled.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.platform.is_unlocked()
|
||||
pub fn device_path(&self) -> &str {
|
||||
&self.device_path
|
||||
}
|
||||
|
||||
/// Read drive status and feature flags.
|
||||
pub fn status(&mut self) -> Result<DriveStatus> {
|
||||
self.platform.status(self.scsi.as_mut())
|
||||
/// Initialize drive — unlock + firmware upload.
|
||||
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
|
||||
pub fn init(&mut self) -> Result<()> {
|
||||
match self.driver {
|
||||
Some(ref mut d) => d.init(self.scsi.as_mut()),
|
||||
None => Err(Error::UnsupportedDrive {
|
||||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||||
product_id: self.drive_id.product_id.trim().to_string(),
|
||||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read drive configuration block.
|
||||
pub fn read_config(&mut self) -> Result<Vec<u8>> {
|
||||
self.platform.read_config(self.scsi.as_mut())
|
||||
/// Probe disc surface so the drive firmware learns optimal read speeds
|
||||
/// per region. After this the host reads at max speed and the drive
|
||||
/// manages zones internally.
|
||||
pub fn probe_disc(&mut self) -> Result<()> {
|
||||
match self.driver {
|
||||
Some(ref mut d) => d.probe_disc(self.scsi.as_mut()),
|
||||
None => Err(Error::UnsupportedDrive {
|
||||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||||
product_id: self.drive_id.product_id.trim().to_string(),
|
||||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read hardware register.
|
||||
pub fn read_register(&mut self, index: u8) -> Result<[u8; 16]> {
|
||||
self.platform.read_register(self.scsi.as_mut(), index)
|
||||
pub fn is_ready(&self) -> bool {
|
||||
match self.driver {
|
||||
Some(ref d) => d.is_ready(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calibrate read speed for the current disc.
|
||||
pub fn calibrate(&mut self) -> Result<()> {
|
||||
self.platform.calibrate(self.scsi.as_mut())
|
||||
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00, (count >> 8) as u8, count as u8, 0x00,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// Read raw disc sectors.
|
||||
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)
|
||||
pub fn read_content(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00, (count >> 8) as u8, count as u8, 0x00,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// Generic 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)
|
||||
pub fn set_speed(&mut self, speed_kbs: u16) {
|
||||
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
|
||||
}
|
||||
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
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);
|
||||
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(())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/sg{}", i);
|
||||
if !std::path::Path::new(&path).exists() { continue; }
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
// Include all optical drives (peripheral device type 0x05)
|
||||
if id.raw_inquiry.len() > 0 && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drives
|
||||
}
|
||||
|
||||
pub fn find_drive() -> Option<String> {
|
||||
find_drives().into_iter().next().map(|(path, _)| path)
|
||||
}
|
||||
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
if path.contains("/sg") {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
return Ok((path.to_string(), None));
|
||||
}
|
||||
if path.contains("/sr") {
|
||||
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
|
||||
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
|
||||
drop(sr_transport);
|
||||
for (sg_path, sg_id) in find_drives() {
|
||||
if sg_id.vendor_id == sr_id.vendor_id
|
||||
&& sg_id.product_id == sr_id.product_id
|
||||
&& sg_id.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning = format!(
|
||||
"{} is a block device (sr) — using {} (sg) for raw access", path, sg_path
|
||||
);
|
||||
return Ok((sg_path, Some(warning)));
|
||||
}
|
||||
}
|
||||
return Ok((path.to_string(), Some(format!(
|
||||
"{} is a block device (sr) — no matching sg device found", path
|
||||
))));
|
||||
}
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
Ok((path.to_string(), None))
|
||||
}
|
||||
|
||||
fn create_driver(platform: profile::Platform, profile: &DriveProfile) -> Result<Box<dyn PlatformDriver>> {
|
||||
match platform {
|
||||
profile::Platform::Mt1959A => Ok(Box::new(Mt1959::new(profile.clone(), false))),
|
||||
profile::Platform::Mt1959B => Ok(Box::new(Mt1959::new(profile.clone(), true))),
|
||||
profile::Platform::Renesas => Err(Error::UnsupportedDrive {
|
||||
vendor_id: profile.identity.vendor_id.trim().to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: "Renesas not yet implemented".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
+186
-54
@@ -1,92 +1,224 @@
|
||||
/// 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 is a code with structured data. No English text.
|
||||
//! Applications map codes to localized messages.
|
||||
//!
|
||||
//! # 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 |
|
||||
//! | E8xxx | Keydb errors |
|
||||
//! | E9xxx | Mux 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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_WRITE_ERROR: u16 = 5001;
|
||||
// Disc format (6xxx)
|
||||
pub const E_DISC_READ: u16 = 6000;
|
||||
pub const E_MPLS_PARSE: u16 = 6001;
|
||||
pub const E_CLPI_PARSE: u16 = 6002;
|
||||
pub const E_UDF_NOT_FOUND: u16 = 6003;
|
||||
pub const E_DISC_NO_TITLES: u16 = 6004;
|
||||
pub const E_DISC_TITLE_RANGE: u16 = 6005;
|
||||
pub const E_DISC_NO_EXTENTS: u16 = 6006;
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
pub const E_AACS_CERT_SHORT: u16 = 7001;
|
||||
pub const E_AACS_AGID_ALLOC: u16 = 7002;
|
||||
pub const E_AACS_CERT_REJECTED: u16 = 7003;
|
||||
pub const E_AACS_CERT_READ: u16 = 7004;
|
||||
pub const E_AACS_CERT_VERIFY: u16 = 7005;
|
||||
pub const E_AACS_KEY_READ: u16 = 7006;
|
||||
pub const E_AACS_KEY_REJECTED: u16 = 7007;
|
||||
pub const E_AACS_KEY_VERIFY: u16 = 7008;
|
||||
pub const E_AACS_VID_READ: u16 = 7009;
|
||||
pub const E_AACS_VID_MAC: u16 = 7010;
|
||||
pub const E_AACS_DATA_KEY: u16 = 7011;
|
||||
pub const E_AACS_VUK_DERIVE: u16 = 7012;
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
pub const E_KEYDB_HTTP: u16 = 8001;
|
||||
pub const E_KEYDB_INVALID: u16 = 8002;
|
||||
pub const E_KEYDB_WRITE: u16 = 8003;
|
||||
pub const E_KEYDB_PARSE: u16 = 8004;
|
||||
pub const E_KEYDB_LOAD: u16 = 8005;
|
||||
// Mux (9xxx)
|
||||
pub const E_MUX_LOOKAHEAD: u16 = 9000;
|
||||
pub const E_MUX_WRITE: u16 = 9001;
|
||||
|
||||
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Structured error with numeric code and context data. No English text.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
// 1xxx — Device errors
|
||||
// Device
|
||||
DeviceNotFound { path: String },
|
||||
DevicePermission { path: String },
|
||||
|
||||
// 2xxx — Profile errors
|
||||
// Profile
|
||||
UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String },
|
||||
ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String },
|
||||
ProfileParse { detail: String },
|
||||
ProfileParse,
|
||||
|
||||
// 3xxx — Unlock errors
|
||||
UnlockFailed { detail: String },
|
||||
// Unlock
|
||||
UnlockFailed,
|
||||
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
||||
NotUnlocked,
|
||||
NotCalibrated,
|
||||
|
||||
// 4xxx — SCSI errors
|
||||
// SCSI
|
||||
ScsiError { opcode: u8, status: u8, sense_key: u8 },
|
||||
ScsiTimeout { opcode: u8 },
|
||||
|
||||
// 5xxx — I/O errors
|
||||
// I/O
|
||||
IoError { source: std::io::Error },
|
||||
WriteError,
|
||||
|
||||
// Disc format
|
||||
DiscRead { sector: u64 },
|
||||
MplsParse,
|
||||
ClpiParse,
|
||||
UdfNotFound { path: String },
|
||||
DiscNoTitles,
|
||||
DiscTitleRange { index: usize, count: usize },
|
||||
DiscNoExtents,
|
||||
|
||||
// AACS
|
||||
AacsNoKeys,
|
||||
AacsCertShort,
|
||||
AacsAgidAlloc,
|
||||
AacsCertRejected,
|
||||
AacsCertRead,
|
||||
AacsCertVerify,
|
||||
AacsKeyRead,
|
||||
AacsKeyRejected,
|
||||
AacsKeyVerify,
|
||||
AacsVidRead,
|
||||
AacsVidMac,
|
||||
AacsDataKey,
|
||||
AacsVukDerive,
|
||||
|
||||
// Keydb
|
||||
KeydbConnect { host: String },
|
||||
KeydbHttp { status: u16 },
|
||||
KeydbInvalid,
|
||||
KeydbWrite { path: String },
|
||||
KeydbParse,
|
||||
KeydbLoad { path: String },
|
||||
|
||||
// Mux
|
||||
MuxLookahead,
|
||||
MuxWrite,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Numeric error code for programmatic handling.
|
||||
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::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::WriteError => E_WRITE_ERROR,
|
||||
Error::DiscRead { .. } => E_DISC_READ,
|
||||
Error::MplsParse => E_MPLS_PARSE,
|
||||
Error::ClpiParse => E_CLPI_PARSE,
|
||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||
Error::DiscNoTitles => E_DISC_NO_TITLES,
|
||||
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
||||
Error::DiscNoExtents => E_DISC_NO_EXTENTS,
|
||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
||||
Error::AacsCertRejected => E_AACS_CERT_REJECTED,
|
||||
Error::AacsCertRead => E_AACS_CERT_READ,
|
||||
Error::AacsCertVerify => E_AACS_CERT_VERIFY,
|
||||
Error::AacsKeyRead => E_AACS_KEY_READ,
|
||||
Error::AacsKeyRejected => E_AACS_KEY_REJECTED,
|
||||
Error::AacsKeyVerify => E_AACS_KEY_VERIFY,
|
||||
Error::AacsVidRead => E_AACS_VID_READ,
|
||||
Error::AacsVidMac => E_AACS_VID_MAC,
|
||||
Error::AacsDataKey => E_AACS_DATA_KEY,
|
||||
Error::AacsVukDerive => E_AACS_VUK_DERIVE,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
|
||||
Error::KeydbParse => E_KEYDB_PARSE,
|
||||
Error::KeydbLoad { .. } => E_KEYDB_LOAD,
|
||||
Error::MuxLookahead => E_MUX_LOOKAHEAD,
|
||||
Error::MuxWrite => E_MUX_WRITE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default Display — terse, for logs. Applications should format their own messages.
|
||||
/// Display: "E{code}" with structured data. No English words.
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::DeviceNotFound { path } => write!(f, "E1000: device not found: {path}"),
|
||||
Error::DevicePermission { path } => write!(f, "E1001: permission denied: {path}"),
|
||||
Error::DeviceNotFound { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DevicePermission { path } =>
|
||||
write!(f, "E{}: {}", self.code(), 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{}: {} {} {}", self.code(),
|
||||
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{}: {} {} {}", self.code(),
|
||||
vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
|
||||
Error::SignatureMismatch { expected, got } =>
|
||||
write!(f, "E3001: signature mismatch: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
|
||||
write!(f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
|
||||
self.code(),
|
||||
expected[0], expected[1], expected[2], expected[3],
|
||||
got[0], got[1], got[2], got[3]),
|
||||
Error::NotUnlocked => write!(f, "E3002: not unlocked"),
|
||||
Error::NotCalibrated => write!(f, "E3003: 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}"),
|
||||
write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key),
|
||||
Error::ScsiTimeout { opcode } =>
|
||||
write!(f, "E{}: 0x{:02x}", self.code(), opcode),
|
||||
Error::IoError { source } =>
|
||||
write!(f, "E{}: {}", self.code(), source),
|
||||
Error::DiscRead { sector } =>
|
||||
write!(f, "E{}: {}", self.code(), sector),
|
||||
Error::UdfNotFound { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DiscTitleRange { index, count } =>
|
||||
write!(f, "E{}: {}/{}", self.code(), index, count),
|
||||
Error::KeydbConnect { host } =>
|
||||
write!(f, "E{}: {}", self.code(), host),
|
||||
Error::KeydbHttp { status } =>
|
||||
write!(f, "E{}: {}", self.code(), status),
|
||||
Error::KeydbWrite { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::KeydbLoad { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
// Simple codes — no extra data
|
||||
_ => write!(f, "E{}", self.code()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Event system for progress and status reporting.
|
||||
//!
|
||||
//! The lib fires events during operations like rip().
|
||||
//! The app registers a callback to receive them.
|
||||
//! No display logic, no English text — just data.
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! disc.rip(&mut session, 0, output, |event| {
|
||||
//! match event.kind {
|
||||
//! EventKind::BytesRead { bytes, total } => update_progress(bytes, total),
|
||||
//! EventKind::ReadError { sector, .. } => log_error(sector),
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! });
|
||||
//! ```
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// An event fired by the lib during operations.
|
||||
#[derive(Debug)]
|
||||
pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// Types of events the lib can fire.
|
||||
#[derive(Debug)]
|
||||
pub enum EventKind {
|
||||
/// Bytes successfully read and written to output.
|
||||
BytesRead {
|
||||
/// Bytes written so far.
|
||||
bytes: u64,
|
||||
/// Total bytes expected (0 if unknown).
|
||||
total: u64,
|
||||
},
|
||||
|
||||
/// A read error occurred. The lib will retry automatically.
|
||||
ReadError {
|
||||
/// Sector that failed.
|
||||
sector: u64,
|
||||
/// Error code.
|
||||
error: Error,
|
||||
},
|
||||
|
||||
/// Retrying a failed read.
|
||||
Retry {
|
||||
/// Current attempt number (1-based).
|
||||
attempt: u32,
|
||||
},
|
||||
|
||||
/// Drive speed changed (error recovery or restoration).
|
||||
SpeedChange {
|
||||
/// New speed in KB/s (0xFFFF = max).
|
||||
speed_kbs: u16,
|
||||
},
|
||||
|
||||
/// Starting a new disc extent.
|
||||
ExtentStart {
|
||||
/// Extent index (0-based).
|
||||
index: usize,
|
||||
/// First sector of extent.
|
||||
start_sector: u64,
|
||||
/// Number of sectors in extent.
|
||||
sector_count: u64,
|
||||
},
|
||||
|
||||
/// Operation complete.
|
||||
Complete {
|
||||
/// Total bytes written.
|
||||
bytes: u64,
|
||||
/// Total read errors encountered.
|
||||
errors: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A no-op event handler. Ignores all events.
|
||||
pub fn ignore(_event: Event) {}
|
||||
+29
-1
@@ -39,8 +39,14 @@ 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>,
|
||||
|
||||
/// Raw GET CONFIGURATION Feature 010Ch response bytes.
|
||||
pub raw_gc_010c: Vec<u8>,
|
||||
}
|
||||
|
||||
impl DriveId {
|
||||
@@ -63,10 +69,30 @@ 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(),
|
||||
raw_gc_010c: gc[..result.bytes_transferred].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,7 +100,9 @@ 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(),
|
||||
raw_gc_010c: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
//! KEYDB.cfg updater — HTTP GET, unzip, verify, save.
|
||||
//!
|
||||
//! Zero external HTTP dependencies. Raw TCP for HTTP GET.
|
||||
//! Uses `zip` and `flate2` (already in deps) for extraction.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Standard keydb storage path.
|
||||
pub fn default_path() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").map_err(|_| Error::KeydbWrite {
|
||||
path: "HOME".into(),
|
||||
})?;
|
||||
Ok(PathBuf::from(home).join(".config").join("freemkv").join("keydb.cfg"))
|
||||
}
|
||||
|
||||
/// Download a KEYDB from a URL, verify, save to the standard path.
|
||||
pub fn update(url: &str) -> Result<UpdateResult> {
|
||||
let body = http_get(url)?;
|
||||
save(&body)
|
||||
}
|
||||
|
||||
/// Verify and save raw keydb bytes (plain text, .zip, or .gz).
|
||||
pub fn save(data: &[u8]) -> Result<UpdateResult> {
|
||||
let text = if data.starts_with(b"PK\x03\x04") {
|
||||
extract_zip(data)?
|
||||
} else if data.starts_with(&[0x1f, 0x8b]) {
|
||||
let mut dec = flate2::read::GzDecoder::new(data);
|
||||
let mut out = String::new();
|
||||
dec.read_to_string(&mut out).map_err(|_| Error::KeydbParse)?;
|
||||
out
|
||||
} else {
|
||||
String::from_utf8(data.to_vec()).map_err(|_| Error::KeydbParse)?
|
||||
};
|
||||
|
||||
let entries = text.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim();
|
||||
t.starts_with("0x") || t.starts_with("| DK") || t.starts_with("| PK") || t.starts_with("| HC")
|
||||
})
|
||||
.count();
|
||||
|
||||
if entries == 0 {
|
||||
return Err(Error::KeydbInvalid);
|
||||
}
|
||||
|
||||
let path = default_path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|_| Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
})?;
|
||||
}
|
||||
std::fs::write(&path, &text).map_err(|_| Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
})?;
|
||||
|
||||
Ok(UpdateResult { path, entries, bytes: text.len() })
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateResult {
|
||||
pub path: PathBuf,
|
||||
pub entries: usize,
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
fn http_get(url: &str) -> Result<Vec<u8>> {
|
||||
let (host, port, path) = parse_url(url)?;
|
||||
|
||||
for _ in 0..5 {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
|
||||
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n",
|
||||
path, host
|
||||
);
|
||||
stream.write_all(request.as_bytes()).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
|
||||
let header_end = find_header_end(&response).ok_or(Error::KeydbParse)?;
|
||||
let headers = std::str::from_utf8(&response[..header_end]).unwrap_or("");
|
||||
let body = &response[header_end + 4..];
|
||||
|
||||
if let Some(location) = extract_header(headers, "Location") {
|
||||
return http_get(&location);
|
||||
}
|
||||
|
||||
let status = parse_status(headers);
|
||||
if status != 200 {
|
||||
return Err(Error::KeydbHttp { status });
|
||||
}
|
||||
|
||||
return Ok(body.to_vec());
|
||||
}
|
||||
|
||||
Err(Error::KeydbHttp { status: 302 })
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Result<(String, u16, String)> {
|
||||
let url = url.strip_prefix("http://").ok_or(Error::KeydbParse)?;
|
||||
let (host_port, path) = match url.find('/') {
|
||||
Some(i) => (&url[..i], &url[i..]),
|
||||
None => (url, "/"),
|
||||
};
|
||||
let (host, port) = match host_port.find(':') {
|
||||
Some(i) => (&host_port[..i], host_port[i+1..].parse().unwrap_or(80)),
|
||||
None => (host_port, 80u16),
|
||||
};
|
||||
Ok((host.to_string(), port, path.to_string()))
|
||||
}
|
||||
|
||||
fn parse_status(headers: &str) -> u16 {
|
||||
headers.lines().next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn find_header_end(data: &[u8]) -> Option<usize> {
|
||||
data.windows(4).position(|w| w == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
fn extract_header<'a>(headers: &'a str, name: &str) -> Option<String> {
|
||||
for line in headers.lines() {
|
||||
if line.len() > name.len() + 2
|
||||
&& line[..name.len()].eq_ignore_ascii_case(name)
|
||||
&& line.as_bytes()[name.len()] == b':'
|
||||
{
|
||||
return Some(line[name.len() + 1..].trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_zip(data: &[u8]) -> Result<String> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
|
||||
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).map_err(|_| Error::KeydbParse)?;
|
||||
return Ok(text);
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::KeydbInvalid)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+75
-28
@@ -1,38 +1,71 @@
|
||||
//! 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, find_drive};
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let mut session = DriveSession::open(
|
||||
//! Path::new("/dev/sr0"),
|
||||
//! ).unwrap();
|
||||
//! let device = find_drive().expect("no optical drive found");
|
||||
//! let mut session = DriveSession::open(Path::new(&device)).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;
|
||||
@@ -41,11 +74,25 @@ pub mod platform;
|
||||
pub mod drive;
|
||||
pub mod identity;
|
||||
pub mod speed;
|
||||
pub mod udf;
|
||||
pub mod mpls;
|
||||
pub mod clpi;
|
||||
pub mod disc;
|
||||
pub mod aacs;
|
||||
pub mod labels;
|
||||
pub mod keydb;
|
||||
pub mod event;
|
||||
pub mod mux;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use drive::DriveSession;
|
||||
pub use event::{Event, EventKind};
|
||||
pub use drive::{DriveSession, find_drive, find_drives, resolve_device};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::{DriveProfile, Chipset};
|
||||
pub use platform::{Platform, DriveStatus};
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use speed::DriveSpeed;
|
||||
pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
|
||||
Codec, HdrFormat, ColorSpace,
|
||||
Extent, ContentReader, AacsState, KeySource, ScanOptions};
|
||||
pub use mux::MkvStream;
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
//! MPLS playlist parser — Blu-ray movie playlists.
|
||||
//!
|
||||
//! Each .mpls file in BDMV/PLAYLIST/ defines a title.
|
||||
//! Contains play items (clips) with in/out timestamps,
|
||||
//! stream info (video, audio, subtitle tracks).
|
||||
//!
|
||||
//! Reference: https://github.com/lw/BluRay/wiki/MPLS
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Parsed MPLS playlist.
|
||||
#[derive(Debug)]
|
||||
pub struct Playlist {
|
||||
/// MPLS version (e.g. "0200" or "0300")
|
||||
pub version: String,
|
||||
/// Play items in playback order
|
||||
pub play_items: Vec<PlayItem>,
|
||||
/// Streams from the first play item's STN table
|
||||
pub streams: Vec<StreamEntry>,
|
||||
}
|
||||
|
||||
/// A play item — one clip reference with in/out times.
|
||||
#[derive(Debug)]
|
||||
pub struct PlayItem {
|
||||
/// Clip filename without extension (e.g. "00001")
|
||||
pub clip_id: String,
|
||||
/// In-time in 45kHz ticks
|
||||
pub in_time: u32,
|
||||
/// Out-time in 45kHz ticks
|
||||
pub out_time: u32,
|
||||
/// Connection condition (1=seamless, 5/6=non-seamless)
|
||||
pub connection_condition: u8,
|
||||
}
|
||||
|
||||
/// 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::MplsParse);
|
||||
}
|
||||
if &data[0..4] != b"MPLS" {
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
|
||||
if playlist_start + 10 > data.len() {
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
|
||||
let pl = &data[playlist_start..];
|
||||
let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize;
|
||||
|
||||
let mut play_items = Vec::with_capacity(num_play_items);
|
||||
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 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; }
|
||||
|
||||
let clip_id = String::from_utf8_lossy(&item[0..5]).to_string();
|
||||
let connection_condition = item[9] & 0x0F;
|
||||
let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]);
|
||||
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
|
||||
|
||||
// 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; }
|
||||
}
|
||||
// Secondary PG (PiP subtitles) — must consume to keep spos aligned
|
||||
for _ in 0..n_pip_pg {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 3) {
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip reference data: num_refs(1) + reserved(1) + refs + padding
|
||||
if next < item.len() {
|
||||
let n_refs = item[next] as usize;
|
||||
spos = next + 2 + n_refs + (n_refs % 2);
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Dolby Vision enhancement layer
|
||||
for _ in 0..n_dv {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
entry.stream_type = 7;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
}
|
||||
|
||||
play_items.push(PlayItem {
|
||||
clip_id,
|
||||
in_time,
|
||||
out_time,
|
||||
connection_condition,
|
||||
});
|
||||
|
||||
pos += 2 + item_length;
|
||||
}
|
||||
|
||||
Ok(Playlist {
|
||||
version,
|
||||
play_items,
|
||||
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)
|
||||
// Exception: PGS (0x90/0x91) in audio slot uses PG layout: coding_type(1) + language(3)
|
||||
if coding_type == 0x90 || coding_type == 0x91 {
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
}
|
||||
} else {
|
||||
if sa.len() >= 2 {
|
||||
audio_format = (sa[1] >> 4) & 0x0F;
|
||||
audio_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if sa.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sa[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
3 | 4 => {
|
||||
// PG/IG: coding_type(1) + language(3)
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
// Secondary audio: same as primary audio
|
||||
if sa.len() >= 2 {
|
||||
audio_format = (sa[1] >> 4) & 0x0F;
|
||||
audio_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if sa.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sa[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
6 | 7 => {
|
||||
// Secondary video: same as primary video
|
||||
if sa.len() >= 2 {
|
||||
video_format = (sa[1] >> 4) & 0x0F;
|
||||
video_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if coding_type == 0x24 && sa.len() > 2 {
|
||||
dynamic_range = (sa[2] >> 4) & 0x0F;
|
||||
color_space_val = sa[2] & 0x0F;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! AC3 (Dolby Digital) / EAC3 (Dolby Digital Plus) frame parser.
|
||||
//!
|
||||
//! AC3 frames are self-contained and always start with syncword 0x0B77.
|
||||
//! Each PES packet typically contains exactly one AC3 frame.
|
||||
//! All AC3 frames are effectively keyframes (no inter-frame dependencies).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct Ac3Parser;
|
||||
|
||||
impl Ac3Parser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for Ac3Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// AC3: each PES = one frame, always a keyframe
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// AC3 doesn't need codecPrivate in MKV
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! DTS / DTS-HD elementary stream parser.
|
||||
//!
|
||||
//! DTS core syncword: 0x7FFE8001 (32 bits).
|
||||
//! DTS-HD MA/HRA extension follows the core frame.
|
||||
//! All frames are keyframes (no inter-frame dependencies).
|
||||
//! Each PES packet = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct DtsParser;
|
||||
|
||||
impl DtsParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for DtsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! H.264 (AVC) elementary stream parser.
|
||||
//!
|
||||
//! Extracts SPS and PPS NAL units for MKV codecPrivate.
|
||||
//! Detects keyframes (IDR slices).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// H.264 NAL unit types we care about.
|
||||
const NAL_SLICE_IDR: u8 = 5;
|
||||
const NAL_SPS: u8 = 7;
|
||||
const NAL_PPS: u8 = 8;
|
||||
const NAL_AUD: u8 = 9;
|
||||
|
||||
pub struct H264Parser {
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl H264Parser {
|
||||
pub fn new() -> Self {
|
||||
Self { sps: None, pps: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for H264Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// Scan NAL units for SPS, PPS, and IDR detection
|
||||
let mut keyframe = false;
|
||||
let mut frame_data = Vec::new();
|
||||
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
|
||||
match nal_type {
|
||||
NAL_SPS => {
|
||||
self.sps = Some(nal.to_vec());
|
||||
}
|
||||
NAL_PPS => {
|
||||
self.pps = Some(nal.to_vec());
|
||||
}
|
||||
NAL_SLICE_IDR => {
|
||||
keyframe = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B (start code prefixed) to length-prefixed NALUs.
|
||||
// MKV with AVCDecoderConfigurationRecord expects 4-byte length prefix per NALU.
|
||||
// Skip SPS/PPS/AUD NALUs — they're in codecPrivate, not in frame data.
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
// Skip parameter sets and access unit delimiters
|
||||
if nal_type == NAL_SPS || nal_type == NAL_PPS || nal_type == NAL_AUD {
|
||||
continue;
|
||||
}
|
||||
// 4-byte big-endian length prefix
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
|
||||
if frame_data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: frame_data,
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// Build AVCDecoderConfigurationRecord from SPS + PPS
|
||||
let sps = self.sps.as_ref()?;
|
||||
let pps = self.pps.as_ref()?;
|
||||
|
||||
// AVCDecoderConfigurationRecord (ISO 14496-15):
|
||||
// configurationVersion = 1
|
||||
// AVCProfileIndication = SPS[1]
|
||||
// profile_compatibility = SPS[2]
|
||||
// AVCLevelIndication = SPS[3]
|
||||
// lengthSizeMinusOne = 3 (4-byte length prefix)
|
||||
// numOfSequenceParameterSets = 1
|
||||
// sequenceParameterSetLength = sps.len()
|
||||
// sequenceParameterSetNALUnit = sps
|
||||
// numOfPictureParameterSets = 1
|
||||
// pictureParameterSetLength = pps.len()
|
||||
// pictureParameterSetNALUnit = pps
|
||||
|
||||
let mut record = Vec::new();
|
||||
record.push(1); // configurationVersion
|
||||
record.push(sps[1]); // profile
|
||||
record.push(sps[2]); // compatibility
|
||||
record.push(sps[3]); // level
|
||||
record.push(0xFF); // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
|
||||
record.push(0xE1); // 3 bits reserved (111) + 5 bits numSPS (1)
|
||||
record.push((sps.len() >> 8) as u8);
|
||||
record.push(sps.len() as u8);
|
||||
record.extend_from_slice(sps);
|
||||
record.push(1); // numPPS
|
||||
record.push((pps.len() >> 8) as u8);
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator over NAL units in Annex B byte stream.
|
||||
/// Finds start codes (00 00 01 or 00 00 00 01) and yields the data between them.
|
||||
struct NalIterator<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> NalIterator<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
// Skip to first start code
|
||||
let pos = find_start_code(data, 0).unwrap_or(data.len());
|
||||
Self { data, pos }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for NalIterator<'a> {
|
||||
type Item = &'a [u8];
|
||||
|
||||
fn next(&mut self) -> Option<&'a [u8]> {
|
||||
if self.pos >= self.data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip the start code at current position
|
||||
let nal_start = skip_start_code(self.data, self.pos)?;
|
||||
|
||||
// Find next start code (or end of data)
|
||||
let nal_end = find_start_code(self.data, nal_start).unwrap_or(self.data.len());
|
||||
|
||||
// Remove trailing zeros (part of next start code's zero prefix)
|
||||
let mut end = nal_end;
|
||||
while end > nal_start && self.data[end - 1] == 0x00 {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
self.pos = nal_end;
|
||||
|
||||
if end > nal_start {
|
||||
Some(&self.data[nal_start..end])
|
||||
} else {
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the position of the next start code (00 00 01) at or after `from`.
|
||||
pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
for i in from..data.len() - 2 {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Skip past the start code at position `pos`, returning the first byte after it.
|
||||
pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> {
|
||||
if pos + 2 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
if data[pos] == 0x00 && data[pos + 1] == 0x00 {
|
||||
if pos + 3 < data.len() && data[pos + 2] == 0x00 && data[pos + 3] == 0x01 {
|
||||
return Some(pos + 4); // 4-byte start code
|
||||
}
|
||||
if data[pos + 2] == 0x01 {
|
||||
return Some(pos + 3); // 3-byte start code
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! HEVC (H.265) elementary stream parser.
|
||||
//!
|
||||
//! Extracts VPS, SPS, PPS NAL units for MKV codecPrivate.
|
||||
//! Detects keyframes (IRAP pictures: IDR, CRA, BLA).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
use super::h264::{find_start_code, skip_start_code};
|
||||
|
||||
// HEVC NAL unit types
|
||||
const NAL_VPS: u8 = 32;
|
||||
const NAL_SPS: u8 = 33;
|
||||
const NAL_PPS: u8 = 34;
|
||||
const NAL_AUD: u8 = 35;
|
||||
// IRAP types (keyframes): BLA, IDR, CRA
|
||||
const NAL_BLA_W_LP: u8 = 16;
|
||||
const NAL_RSV_IRAP_VCL23: u8 = 23;
|
||||
|
||||
pub struct HevcParser {
|
||||
vps: Option<Vec<u8>>,
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl HevcParser {
|
||||
pub fn new() -> Self {
|
||||
Self { vps: None, sps: None, pps: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for HevcParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
|
||||
// Scan NAL units
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(data, sc_pos) {
|
||||
let next = find_start_code(data, nal_start).unwrap_or(data.len());
|
||||
let mut end = next;
|
||||
while end > nal_start && data[end - 1] == 0x00 { end -= 1; }
|
||||
|
||||
if nal_start < data.len() {
|
||||
// HEVC NAL header: 2 bytes. Type is bits 1-6 of first byte.
|
||||
let nal_type = (data[nal_start] >> 1) & 0x3F;
|
||||
|
||||
match nal_type {
|
||||
NAL_VPS => self.vps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_SPS => self.sps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_PPS => self.pps = Some(data[nal_start..end].to_vec()),
|
||||
t if t >= NAL_BLA_W_LP && t <= NAL_RSV_IRAP_VCL23 => {
|
||||
keyframe = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B to length-prefixed NALUs.
|
||||
// Skip VPS/SPS/PPS/AUD — they're in codecPrivate.
|
||||
let mut frame_data = Vec::new();
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(&pes.data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(&pes.data, sc_pos) {
|
||||
let next = find_start_code(&pes.data, nal_start).unwrap_or(pes.data.len());
|
||||
let mut end = next;
|
||||
while end > nal_start && pes.data[end - 1] == 0x00 { end -= 1; }
|
||||
|
||||
if nal_start < pes.data.len() {
|
||||
let nal_type = (pes.data[nal_start] >> 1) & 0x3F;
|
||||
// Skip parameter sets and AUD
|
||||
if nal_type != NAL_VPS && nal_type != NAL_SPS && nal_type != NAL_PPS && nal_type != NAL_AUD {
|
||||
let nal = &pes.data[nal_start..end];
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if frame_data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: frame_data,
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// HEVCDecoderConfigurationRecord (ISO 14496-15)
|
||||
let vps = self.vps.as_ref()?;
|
||||
let sps = self.sps.as_ref()?;
|
||||
let pps = self.pps.as_ref()?;
|
||||
|
||||
// Simplified: store as arrays in Annex B format
|
||||
// Full HEVCDecoderConfigurationRecord is complex — for now, concatenate
|
||||
let mut record = Vec::new();
|
||||
|
||||
// Minimal HEVCDecoderConfigurationRecord header
|
||||
record.push(1); // configurationVersion
|
||||
// General profile space, tier flag, profile IDC from SPS
|
||||
if sps.len() > 3 {
|
||||
record.push(sps[1]); // general_profile_space + general_tier_flag + general_profile_idc
|
||||
} else {
|
||||
record.push(0);
|
||||
}
|
||||
// general_profile_compatibility_flags (4 bytes)
|
||||
record.extend_from_slice(&[0, 0, 0, 0]);
|
||||
// general_constraint_indicator_flags (6 bytes)
|
||||
record.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
|
||||
// general_level_idc
|
||||
record.push(if sps.len() > 12 { sps[12] } else { 0 });
|
||||
// min_spatial_segmentation_idc (4 + 12 bits)
|
||||
record.extend_from_slice(&[0xF0, 0x00]);
|
||||
// parallelismType (6 + 2 bits)
|
||||
record.push(0xFC);
|
||||
// chromaFormat (6 + 2 bits)
|
||||
record.push(0xFC | 1); // 4:2:0
|
||||
// bitDepthLumaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// bitDepthChromaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// avgFrameRate
|
||||
record.extend_from_slice(&[0, 0]);
|
||||
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
|
||||
record.push(0x03); // lengthSizeMinusOne = 3 (4 bytes)
|
||||
// numOfArrays
|
||||
record.push(3); // VPS, SPS, PPS
|
||||
|
||||
// VPS array
|
||||
record.push(0x20 | (NAL_VPS & 0x3F)); // array_completeness + NAL type
|
||||
record.extend_from_slice(&[0, 1]); // numNalus = 1
|
||||
record.push((vps.len() >> 8) as u8);
|
||||
record.push(vps.len() as u8);
|
||||
record.extend_from_slice(vps);
|
||||
|
||||
// SPS array
|
||||
record.push(0x20 | (NAL_SPS & 0x3F));
|
||||
record.extend_from_slice(&[0, 1]);
|
||||
record.push((sps.len() >> 8) as u8);
|
||||
record.push(sps.len() as u8);
|
||||
record.extend_from_slice(sps);
|
||||
|
||||
// PPS array
|
||||
record.push(0x20 | (NAL_PPS & 0x3F));
|
||||
record.extend_from_slice(&[0, 1]);
|
||||
record.push((pps.len() >> 8) as u8);
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Elementary stream codec parsers.
|
||||
//!
|
||||
//! Each parser takes PES packets and produces frames suitable for MKV muxing.
|
||||
//! Responsibilities:
|
||||
//! - Find frame boundaries
|
||||
//! - Extract codec initialization data (SPS/PPS, etc.)
|
||||
//! - Determine keyframe status
|
||||
//! - Convert PTS from 90kHz to nanoseconds
|
||||
|
||||
pub mod ac3;
|
||||
pub mod h264;
|
||||
pub mod hevc;
|
||||
pub mod vc1;
|
||||
pub mod dts;
|
||||
pub mod truehd;
|
||||
pub mod pgs;
|
||||
|
||||
use crate::disc::Codec;
|
||||
use super::ts::PesPacket;
|
||||
|
||||
/// A single frame ready for MKV muxing.
|
||||
pub struct Frame {
|
||||
/// Presentation timestamp in nanoseconds.
|
||||
pub pts_ns: i64,
|
||||
/// Whether this is a keyframe (used for cue points).
|
||||
pub keyframe: bool,
|
||||
/// Frame data (elementary stream bytes).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Convert 90kHz PTS to nanoseconds.
|
||||
pub fn pts_to_ns(pts: i64) -> i64 {
|
||||
pts * 100_000 / 9
|
||||
}
|
||||
|
||||
/// Trait for codec-specific elementary stream parsers.
|
||||
pub trait CodecParser: Send {
|
||||
/// Parse a PES packet into zero or more frames.
|
||||
/// Most codecs: one PES = one frame.
|
||||
/// Some (TrueHD): multiple access units per PES.
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame>;
|
||||
|
||||
/// Get codec initialization data (e.g., SPS+PPS for H.264).
|
||||
/// Returns None until enough data has been seen.
|
||||
fn codec_private(&self) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// Passthrough parser — treats each PES as one frame, no parsing.
|
||||
/// Used for codecs where PES = frame (AC3, DTS, PGS).
|
||||
pub struct PassthroughParser {
|
||||
keyframe: bool,
|
||||
}
|
||||
|
||||
impl PassthroughParser {
|
||||
pub fn new(always_keyframe: bool) -> Self {
|
||||
Self { keyframe: always_keyframe }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for PassthroughParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: self.keyframe,
|
||||
data: pes.data.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the appropriate parser for a codec.
|
||||
pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(h264::H264Parser::new()),
|
||||
Codec::Hevc => Box::new(hevc::HevcParser::new()),
|
||||
Codec::Vc1 => Box::new(vc1::Vc1Parser::new()),
|
||||
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
||||
Codec::Lpcm => Box::new(PassthroughParser::new(true)),
|
||||
_ => Box::new(PassthroughParser::new(true)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||
//!
|
||||
//! PGS segments: PCS, WDS, PDS, ODS, END.
|
||||
//! Each PES packet contains one or more segments.
|
||||
//! All segments are keyframes (no inter-segment dependencies).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct PgsParser;
|
||||
|
||||
impl PgsParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for PgsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Dolby TrueHD / Atmos elementary stream parser.
|
||||
//!
|
||||
//! TrueHD major sync: 0xF8726FBA at a 4-byte aligned position.
|
||||
//! Access units consist of a major sync followed by minor syncs.
|
||||
//! An embedded AC3 core is in substream 0 for backward compatibility.
|
||||
//! All access units are keyframes.
|
||||
//! Each PES packet = one access unit.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct TrueHdParser;
|
||||
|
||||
impl TrueHdParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for TrueHdParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! VC-1 (SMPTE 421M) elementary stream parser.
|
||||
//!
|
||||
//! VC-1 uses start codes similar to MPEG-2.
|
||||
//! Sequence header (0x0F) contains codec initialization data.
|
||||
//! Frame start = Frame header start code (0x0D).
|
||||
//! I-frames (keyframes) are identified from the frame header.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const SC_SEQUENCE_HEADER: u8 = 0x0F;
|
||||
const SC_ENTRY_POINT: u8 = 0x0E;
|
||||
const SC_FRAME: u8 = 0x0D;
|
||||
|
||||
pub struct Vc1Parser {
|
||||
seq_header: Option<Vec<u8>>,
|
||||
entry_point: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Vc1Parser {
|
||||
pub fn new() -> Self {
|
||||
Self { seq_header: None, entry_point: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for Vc1Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
let mut keyframe = false;
|
||||
|
||||
// Scan for start codes (00 00 01 XX)
|
||||
let data = &pes.data;
|
||||
let mut i = 0;
|
||||
while i + 3 < data.len() {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
let sc_type = data[i + 3];
|
||||
match sc_type {
|
||||
SC_SEQUENCE_HEADER => {
|
||||
// Capture everything from here to next start code
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
self.seq_header = Some(data[i..end].to_vec());
|
||||
}
|
||||
SC_ENTRY_POINT => {
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
self.entry_point = Some(data[i..end].to_vec());
|
||||
}
|
||||
SC_FRAME => {
|
||||
// First frame after sequence header + entry point is a keyframe
|
||||
if self.seq_header.is_some() && self.entry_point.is_some() {
|
||||
keyframe = true;
|
||||
}
|
||||
// Also check frame type from bitstream (bit after start code)
|
||||
if i + 4 < data.len() {
|
||||
// For Advanced profile: first 2 bits of frame data indicate type
|
||||
// But simpler: any frame preceded by seq+entry is I-frame
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: pes.data.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// MKV V_MS/VFW/FOURCC requires BITMAPINFOHEADER (40 bytes) + extra codec data.
|
||||
// The sequence header + entry point go as extra data after the header.
|
||||
let sh = self.seq_header.as_ref()?;
|
||||
let ep = self.entry_point.as_ref()?;
|
||||
|
||||
let extra_len = sh.len() + ep.len();
|
||||
let header_size: u32 = 40 + extra_len as u32;
|
||||
|
||||
let mut cp = Vec::with_capacity(header_size as usize);
|
||||
|
||||
// BITMAPINFOHEADER (40 bytes, little-endian)
|
||||
cp.extend_from_slice(&header_size.to_le_bytes()); // biSize
|
||||
cp.extend_from_slice(&1920u32.to_le_bytes()); // biWidth (updated by player)
|
||||
cp.extend_from_slice(&1080u32.to_le_bytes()); // biHeight
|
||||
cp.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
|
||||
cp.extend_from_slice(&24u16.to_le_bytes()); // biBitCount
|
||||
cp.extend_from_slice(b"WVC1"); // biCompression = "WVC1" FOURCC
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biSizeImage
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biXPelsPerMeter
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biYPelsPerMeter
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biClrUsed
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biClrImportant
|
||||
|
||||
// Extra codec data: sequence header + entry point (Annex B)
|
||||
cp.extend_from_slice(sh);
|
||||
cp.extend_from_slice(ep);
|
||||
|
||||
Some(cp)
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
|
||||
for i in from..data.len().saturating_sub(2) {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
//! EBML (Extensible Binary Meta Language) write primitives for Matroska.
|
||||
//!
|
||||
//! EBML uses variable-length integers for element IDs and sizes.
|
||||
//! This module provides low-level writers for constructing MKV files.
|
||||
|
||||
use std::io::{self, Write, Seek, SeekFrom};
|
||||
|
||||
/// Write an EBML element ID (1-4 bytes, already encoded).
|
||||
/// Element IDs are predefined constants — we write them verbatim.
|
||||
pub fn write_id(w: &mut impl Write, id: u32) -> io::Result<()> {
|
||||
if id <= 0x7F {
|
||||
w.write_all(&[id as u8])
|
||||
} else if id <= 0x7FFF {
|
||||
w.write_all(&[(id >> 8) as u8, id as u8])
|
||||
} else if id <= 0x7F_FFFF {
|
||||
w.write_all(&[(id >> 16) as u8, (id >> 8) as u8, id as u8])
|
||||
} else {
|
||||
w.write_all(&[(id >> 24) as u8, (id >> 16) as u8, (id >> 8) as u8, id as u8])
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an EBML variable-length size (1-8 bytes).
|
||||
/// Uses the EBML VINT encoding: leading bits indicate width.
|
||||
pub fn write_size(w: &mut impl Write, size: u64) -> io::Result<()> {
|
||||
if size < 0x7F {
|
||||
w.write_all(&[(size as u8) | 0x80])
|
||||
} else if size < 0x3FFF {
|
||||
w.write_all(&[((size >> 8) as u8) | 0x40, size as u8])
|
||||
} else if size < 0x1F_FFFF {
|
||||
w.write_all(&[
|
||||
((size >> 16) as u8) | 0x20,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
} else if size < 0x0FFF_FFFF {
|
||||
w.write_all(&[
|
||||
((size >> 24) as u8) | 0x10,
|
||||
(size >> 16) as u8,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
} else {
|
||||
// 8-byte size for large elements
|
||||
w.write_all(&[
|
||||
0x01,
|
||||
(size >> 48) as u8,
|
||||
(size >> 40) as u8,
|
||||
(size >> 32) as u8,
|
||||
(size >> 24) as u8,
|
||||
(size >> 16) as u8,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an EBML "unknown size" marker (all 1s in VINT, 8 bytes).
|
||||
/// Used for the Segment element when total size isn't known upfront.
|
||||
pub fn write_unknown_size(w: &mut impl Write) -> io::Result<()> {
|
||||
w.write_all(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
|
||||
}
|
||||
|
||||
/// Write a complete EBML unsigned integer element.
|
||||
pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
if val <= 0xFF {
|
||||
write_size(w, 1)?;
|
||||
w.write_all(&[val as u8])
|
||||
} else if val <= 0xFFFF {
|
||||
write_size(w, 2)?;
|
||||
w.write_all(&[(val >> 8) as u8, val as u8])
|
||||
} else if val <= 0xFF_FFFF {
|
||||
write_size(w, 3)?;
|
||||
w.write_all(&[(val >> 16) as u8, (val >> 8) as u8, val as u8])
|
||||
} else if val <= 0xFFFF_FFFF {
|
||||
write_size(w, 4)?;
|
||||
w.write_all(&[
|
||||
(val >> 24) as u8, (val >> 16) as u8,
|
||||
(val >> 8) as u8, val as u8,
|
||||
])
|
||||
} else {
|
||||
write_size(w, 8)?;
|
||||
w.write_all(&val.to_be_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete EBML signed integer element.
|
||||
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
|
||||
write_uint(w, id, val as u64)
|
||||
}
|
||||
|
||||
/// Write a complete EBML float element (8-byte double).
|
||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, 8)?;
|
||||
w.write_all(&val.to_be_bytes())
|
||||
}
|
||||
|
||||
/// Write a complete EBML UTF-8 string element.
|
||||
pub fn write_string(w: &mut impl Write, id: u32, val: &str) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, val.len() as u64)?;
|
||||
w.write_all(val.as_bytes())
|
||||
}
|
||||
|
||||
/// Write a complete EBML binary element.
|
||||
pub fn write_binary(w: &mut impl Write, id: u32, data: &[u8]) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, data.len() as u64)?;
|
||||
w.write_all(data)
|
||||
}
|
||||
|
||||
/// Start a master element: write ID + placeholder size.
|
||||
/// Returns the file offset of the size field for later fixup.
|
||||
pub fn start_master<W: Write + Seek>(w: &mut W, id: u32) -> io::Result<u64> {
|
||||
write_id(w, id)?;
|
||||
let size_pos = w.stream_position()?;
|
||||
// 8-byte size placeholder (will be overwritten by end_master)
|
||||
w.write_all(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])?;
|
||||
Ok(size_pos)
|
||||
}
|
||||
|
||||
/// End a master element: seek back and write the actual size.
|
||||
pub fn end_master<W: Write + Seek>(w: &mut W, size_pos: u64) -> io::Result<()> {
|
||||
let end_pos = w.stream_position()?;
|
||||
let data_size = end_pos - size_pos - 8; // subtract the 8-byte size field itself
|
||||
w.seek(SeekFrom::Start(size_pos))?;
|
||||
// Write as 8-byte VINT: 0x01 followed by 7 bytes of size
|
||||
w.write_all(&[
|
||||
0x01,
|
||||
(data_size >> 48) as u8,
|
||||
(data_size >> 40) as u8,
|
||||
(data_size >> 32) as u8,
|
||||
(data_size >> 24) as u8,
|
||||
(data_size >> 16) as u8,
|
||||
(data_size >> 8) as u8,
|
||||
data_size as u8,
|
||||
])?;
|
||||
w.seek(SeekFrom::Start(end_pos))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Matroska Element IDs
|
||||
// ============================================================
|
||||
|
||||
// EBML Header
|
||||
pub const EBML: u32 = 0x1A45DFA3;
|
||||
pub const EBML_VERSION: u32 = 0x4286;
|
||||
pub const EBML_READ_VERSION: u32 = 0x42F7;
|
||||
pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2;
|
||||
pub const EBML_MAX_SIZE_LENGTH: u32 = 0x42F3;
|
||||
pub const EBML_DOC_TYPE: u32 = 0x4282;
|
||||
pub const EBML_DOC_TYPE_VERSION: u32 = 0x4287;
|
||||
pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
||||
|
||||
// Segment
|
||||
pub const SEGMENT: u32 = 0x18538067;
|
||||
|
||||
// Seek Head
|
||||
pub const SEEK_HEAD: u32 = 0x114D9B74;
|
||||
pub const SEEK: u32 = 0x4DBB;
|
||||
pub const SEEK_ID: u32 = 0x53AB;
|
||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||
|
||||
// Segment Info
|
||||
pub const INFO: u32 = 0x1549A966;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2AD7B1;
|
||||
pub const DURATION: u32 = 0x4489;
|
||||
pub const MUXING_APP: u32 = 0x4D80;
|
||||
pub const WRITING_APP: u32 = 0x5741;
|
||||
pub const TITLE: u32 = 0x7BA9;
|
||||
|
||||
// Tracks
|
||||
pub const TRACKS: u32 = 0x1654AE6B;
|
||||
pub const TRACK_ENTRY: u32 = 0xAE;
|
||||
pub const TRACK_NUMBER: u32 = 0xD7;
|
||||
pub const TRACK_UID: u32 = 0x73C5;
|
||||
pub const TRACK_TYPE: u32 = 0x83;
|
||||
pub const FLAG_LACING: u32 = 0x9C;
|
||||
pub const FLAG_DEFAULT: u32 = 0x88;
|
||||
pub const FLAG_FORCED: u32 = 0x55AA;
|
||||
pub const LANGUAGE: u32 = 0x22B59C;
|
||||
pub const CODEC_ID: u32 = 0x86;
|
||||
pub const CODEC_PRIVATE: u32 = 0x63A2;
|
||||
pub const DEFAULT_DURATION: u32 = 0x23E383;
|
||||
|
||||
// Video
|
||||
pub const VIDEO: u32 = 0xE0;
|
||||
pub const PIXEL_WIDTH: u32 = 0xB0;
|
||||
pub const PIXEL_HEIGHT: u32 = 0xBA;
|
||||
pub const DISPLAY_WIDTH: u32 = 0x54B0;
|
||||
pub const DISPLAY_HEIGHT: u32 = 0x54BA;
|
||||
pub const COLOUR: u32 = 0x55B0;
|
||||
pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA;
|
||||
pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
||||
pub const PRIMARIES: u32 = 0x55BB;
|
||||
pub const RANGE: u32 = 0x55B9;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
pub const SAMPLING_FREQUENCY: u32 = 0xB5;
|
||||
pub const CHANNELS: u32 = 0x9F;
|
||||
pub const BIT_DEPTH: u32 = 0x6264;
|
||||
|
||||
// Cluster
|
||||
pub const CLUSTER: u32 = 0x1F43B675;
|
||||
pub const CLUSTER_TIMESTAMP: u32 = 0xE7;
|
||||
pub const SIMPLE_BLOCK: u32 = 0xA3;
|
||||
|
||||
// Cues
|
||||
pub const CUES: u32 = 0x1C53BB6B;
|
||||
pub const CUE_POINT: u32 = 0xBB;
|
||||
pub const CUE_TIME: u32 = 0xB3;
|
||||
pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
|
||||
pub const CUE_TRACK: u32 = 0xF7;
|
||||
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
|
||||
|
||||
// Track types
|
||||
pub const TRACK_TYPE_VIDEO: u64 = 1;
|
||||
pub const TRACK_TYPE_AUDIO: u64 = 2;
|
||||
pub const TRACK_TYPE_SUBTITLE: u64 = 17;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn test_write_size() {
|
||||
let mut buf = Vec::new();
|
||||
write_size(&mut buf, 0).unwrap();
|
||||
assert_eq!(buf, [0x80]);
|
||||
|
||||
buf.clear();
|
||||
write_size(&mut buf, 127).unwrap();
|
||||
assert_eq!(buf, [0x40, 127]); // 127 >= 0x7F, uses 2 bytes: (0>>8)|0x40, 127
|
||||
|
||||
buf.clear();
|
||||
write_size(&mut buf, 126).unwrap();
|
||||
assert_eq!(buf, [126 | 0x80]); // 126 < 0x7F, uses 1 byte
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_uint() {
|
||||
let mut buf = Vec::new();
|
||||
write_uint(&mut buf, 0x4286, 1).unwrap(); // EBML_VERSION = 1
|
||||
// ID: 42 86, Size: 81 (1 byte), Data: 01
|
||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_string() {
|
||||
let mut buf = Vec::new();
|
||||
write_string(&mut buf, 0x4282, "matroska").unwrap();
|
||||
// ID: 42 82, Size: 88 (8 bytes), Data: "matroska"
|
||||
assert_eq!(&buf[0..2], &[0x42, 0x82]);
|
||||
assert_eq!(buf[2], 0x88); // size = 8
|
||||
assert_eq!(&buf[3..], b"matroska");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_element() {
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
let pos = start_master(&mut buf, EBML).unwrap();
|
||||
write_uint(&mut buf, EBML_VERSION, 1).unwrap();
|
||||
end_master(&mut buf, pos).unwrap();
|
||||
let data = buf.into_inner();
|
||||
// EBML header: 1A 45 DF A3, then 8-byte size, then content
|
||||
assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! LookaheadBuffer — generic pre-scan buffer for stream pipelines.
|
||||
//!
|
||||
//! Accumulates data up to a configurable limit. When the consumer finds
|
||||
//! what it needs, the buffer can be drained (fast path, no re-read).
|
||||
//! If the buffer fills before the consumer is satisfied, it signals
|
||||
//! overflow — the caller should discard and re-read from the source.
|
||||
//!
|
||||
//! Used by MkvStream to collect SPS/PPS before writing the MKV header.
|
||||
//! Reusable for any stream stage that needs to look ahead.
|
||||
|
||||
/// Default lookahead buffer size: 5 MB.
|
||||
pub const DEFAULT_LOOKAHEAD_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Lookahead buffer states.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LookaheadState {
|
||||
/// Still collecting data, haven't found what we need yet.
|
||||
Collecting,
|
||||
/// Found what we need, buffer has the data ready to drain.
|
||||
Ready,
|
||||
/// Buffer overflowed before finding what we need.
|
||||
/// Caller should discard buffer, finish scanning without buffering,
|
||||
/// then re-read from the source.
|
||||
Overflow,
|
||||
}
|
||||
|
||||
/// A bounded lookahead buffer.
|
||||
pub struct LookaheadBuffer {
|
||||
data: Vec<u8>,
|
||||
max_size: usize,
|
||||
state: LookaheadState,
|
||||
}
|
||||
|
||||
impl LookaheadBuffer {
|
||||
/// Create a new buffer with the given max size.
|
||||
/// Pass 0 for no buffering (always overflows immediately).
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
Self {
|
||||
data: Vec::with_capacity(max_size.min(DEFAULT_LOOKAHEAD_SIZE)),
|
||||
max_size,
|
||||
state: LookaheadState::Collecting,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push data into the buffer. Returns the new state.
|
||||
/// If the buffer would overflow, transitions to Overflow state.
|
||||
pub fn push(&mut self, chunk: &[u8]) -> LookaheadState {
|
||||
if self.state != LookaheadState::Collecting {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
if self.data.len() + chunk.len() > self.max_size {
|
||||
self.state = LookaheadState::Overflow;
|
||||
return self.state;
|
||||
}
|
||||
|
||||
self.data.extend_from_slice(chunk);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Mark the buffer as ready — we found what we need.
|
||||
pub fn mark_ready(&mut self) {
|
||||
if self.state == LookaheadState::Collecting {
|
||||
self.state = LookaheadState::Ready;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the buffered data (only valid in Ready state).
|
||||
pub fn data(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Take ownership of the buffered data, clearing the buffer.
|
||||
pub fn drain(&mut self) -> Vec<u8> {
|
||||
self.state = LookaheadState::Collecting;
|
||||
std::mem::take(&mut self.data)
|
||||
}
|
||||
|
||||
/// Current state.
|
||||
pub fn state(&self) -> LookaheadState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// How many bytes are buffered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
/// Is the buffer empty?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.data.is_empty()
|
||||
}
|
||||
|
||||
/// Max size this buffer can hold.
|
||||
pub fn max_size(&self) -> usize {
|
||||
self.max_size
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_flow() {
|
||||
let mut buf = LookaheadBuffer::new(100);
|
||||
assert_eq!(buf.push(b"hello"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.push(b"world"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.len(), 10);
|
||||
buf.mark_ready();
|
||||
assert_eq!(buf.state(), LookaheadState::Ready);
|
||||
assert_eq!(buf.data(), b"helloworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overflow() {
|
||||
let mut buf = LookaheadBuffer::new(5);
|
||||
assert_eq!(buf.push(b"abc"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.push(b"def"), LookaheadState::Overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_size() {
|
||||
let mut buf = LookaheadBuffer::new(0);
|
||||
assert_eq!(buf.push(b"a"), LookaheadState::Overflow);
|
||||
}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
//! Matroska (MKV) muxer.
|
||||
//!
|
||||
//! Writes EBML header, Segment with tracks, clusters, and cues.
|
||||
//! Designed for streaming writes: clusters are written as data arrives,
|
||||
//! cues and seek head are finalized at the end.
|
||||
|
||||
use std::io::{self, Write, Seek, SeekFrom};
|
||||
use super::ebml;
|
||||
use crate::disc::{VideoStream, AudioStream, SubtitleStream, Codec};
|
||||
|
||||
/// MKV track definition (built from disc stream metadata).
|
||||
pub struct MkvTrack {
|
||||
pub track_type: u64, // 1=video, 2=audio, 17=subtitle
|
||||
pub codec_id: &'static str,
|
||||
pub language: String,
|
||||
pub codec_private: Option<Vec<u8>>,
|
||||
pub is_default: bool,
|
||||
pub is_forced: bool,
|
||||
// Video-specific
|
||||
pub pixel_width: u32,
|
||||
pub pixel_height: u32,
|
||||
// Audio-specific
|
||||
pub sample_rate: f64,
|
||||
pub channels: u8,
|
||||
pub bit_depth: u8,
|
||||
}
|
||||
|
||||
impl MkvTrack {
|
||||
pub fn video(v: &VideoStream) -> Self {
|
||||
let codec_id = match v.codec {
|
||||
Codec::H264 => "V_MPEG4/ISO/AVC",
|
||||
Codec::Hevc => "V_MPEGH/ISO/HEVC",
|
||||
Codec::Vc1 => "V_MS/VFW/FOURCC",
|
||||
Codec::Mpeg2 => "V_MPEG2",
|
||||
_ => "V_MPEG2",
|
||||
};
|
||||
let (w, h) = parse_resolution(&v.resolution);
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_VIDEO,
|
||||
codec_id,
|
||||
language: "und".into(),
|
||||
codec_private: None, // filled later by parser
|
||||
is_default: !v.secondary,
|
||||
is_forced: false,
|
||||
pixel_width: w,
|
||||
pixel_height: h,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio(a: &AudioStream) -> Self {
|
||||
let codec_id = match a.codec {
|
||||
Codec::Ac3 => "A_AC3",
|
||||
Codec::Ac3Plus => "A_EAC3",
|
||||
Codec::TrueHd => "A_TRUEHD",
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => "A_DTS",
|
||||
Codec::Lpcm => "A_PCM/INT/BIG",
|
||||
_ => "A_AC3",
|
||||
};
|
||||
let sr = parse_sample_rate(&a.sample_rate);
|
||||
let ch = parse_channels(&a.channels);
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||||
codec_id,
|
||||
language: a.language.clone(),
|
||||
codec_private: None,
|
||||
is_default: !a.secondary,
|
||||
is_forced: false,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
sample_rate: sr,
|
||||
channels: ch,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subtitle(s: &SubtitleStream) -> Self {
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_SUBTITLE,
|
||||
codec_id: "S_HDMV/PGS",
|
||||
language: s.language.clone(),
|
||||
codec_private: None,
|
||||
is_default: false,
|
||||
is_forced: s.forced,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cue point for seeking.
|
||||
struct CuePoint {
|
||||
timestamp_ms: i64,
|
||||
track: usize,
|
||||
cluster_pos: u64, // relative to Segment start
|
||||
}
|
||||
|
||||
/// MKV muxer. Call write_frame() for each frame, then finish() at the end.
|
||||
pub struct MkvMuxer<W: Write + Seek> {
|
||||
writer: W,
|
||||
segment_start: u64,
|
||||
cluster_open: bool,
|
||||
cluster_pos: u64,
|
||||
cluster_size_pos: u64,
|
||||
cluster_ts_ms: i64,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
|
||||
/// Used to seek back and fill in SPS/PPS after first keyframe.
|
||||
codec_private_slots: Vec<Option<(u64, usize)>>,
|
||||
codec_private_filled: Vec<bool>,
|
||||
}
|
||||
|
||||
/// New cluster every 5 seconds.
|
||||
const CLUSTER_DURATION_MS: i64 = 5000;
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks.
|
||||
pub fn new(mut writer: W, tracks: &[MkvTrack], title: Option<&str>, duration_secs: f64) -> io::Result<Self> {
|
||||
// EBML Header
|
||||
let ebml_pos = ebml::start_master(&mut writer, ebml::EBML)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_VERSION, 1)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_READ_VERSION, 1)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_MAX_ID_LENGTH, 4)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_MAX_SIZE_LENGTH, 8)?;
|
||||
ebml::write_string(&mut writer, ebml::EBML_DOC_TYPE, "matroska")?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_DOC_TYPE_VERSION, 4)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_DOC_TYPE_READ_VERSION, 2)?;
|
||||
ebml::end_master(&mut writer, ebml_pos)?;
|
||||
|
||||
// Segment (unknown size — we'll write cues at the end)
|
||||
ebml::write_id(&mut writer, ebml::SEGMENT)?;
|
||||
ebml::write_unknown_size(&mut writer)?;
|
||||
let segment_start = writer.stream_position()?;
|
||||
|
||||
// Info
|
||||
let info_pos = ebml::start_master(&mut writer, ebml::INFO)?;
|
||||
ebml::write_uint(&mut writer, ebml::TIMESTAMP_SCALE, 1_000_000)?; // 1ms precision
|
||||
if duration_secs > 0.0 {
|
||||
ebml::write_float(&mut writer, ebml::DURATION, duration_secs * 1000.0)?; // in ms
|
||||
}
|
||||
ebml::write_string(&mut writer, ebml::MUXING_APP, "freemkv")?;
|
||||
ebml::write_string(&mut writer, ebml::WRITING_APP, "freemkv")?;
|
||||
if let Some(t) = title {
|
||||
ebml::write_string(&mut writer, ebml::TITLE, t)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, info_pos)?;
|
||||
|
||||
// Tracks
|
||||
let mut codec_private_slots: Vec<Option<(u64, usize)>> = Vec::new();
|
||||
let mut codec_private_filled: Vec<bool> = Vec::new();
|
||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
|
||||
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
|
||||
ebml::write_string(&mut writer, ebml::LANGUAGE, &track.language)?;
|
||||
|
||||
if !track.is_default {
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
|
||||
}
|
||||
if track.is_forced {
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
|
||||
}
|
||||
|
||||
if let Some(ref cp) = track.codec_private {
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
||||
codec_private_slots.push(None); // already filled
|
||||
codec_private_filled.push(true);
|
||||
} else if track.track_type == ebml::TRACK_TYPE_VIDEO {
|
||||
// Reserve space for codecPrivate — will be filled after first keyframe
|
||||
// Reserve 256 bytes (enough for SPS+PPS or VPS+SPS+PPS)
|
||||
let cp_pos = writer.stream_position()?;
|
||||
let placeholder = vec![0u8; 256];
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &placeholder)?;
|
||||
codec_private_slots.push(Some((cp_pos, 256)));
|
||||
codec_private_filled.push(false);
|
||||
} else {
|
||||
codec_private_slots.push(None);
|
||||
codec_private_filled.push(true);
|
||||
}
|
||||
|
||||
// Video-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
|
||||
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?;
|
||||
ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
|
||||
ebml::end_master(&mut writer, vid_pos)?;
|
||||
}
|
||||
|
||||
// Audio-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_AUDIO && track.sample_rate > 0.0 {
|
||||
let aud_pos = ebml::start_master(&mut writer, ebml::AUDIO)?;
|
||||
ebml::write_float(&mut writer, ebml::SAMPLING_FREQUENCY, track.sample_rate)?;
|
||||
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
|
||||
if track.bit_depth > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, aud_pos)?;
|
||||
}
|
||||
|
||||
ebml::end_master(&mut writer, entry_pos)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, tracks_pos)?;
|
||||
|
||||
Ok(Self {
|
||||
writer,
|
||||
segment_start,
|
||||
cluster_open: false,
|
||||
cluster_pos: 0,
|
||||
cluster_size_pos: 0,
|
||||
cluster_ts_ms: 0,
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
codec_private_slots,
|
||||
codec_private_filled,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a single frame.
|
||||
pub fn write_frame(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
let pts_ms = pts_ns / 1_000_000;
|
||||
|
||||
// Start new cluster if needed
|
||||
if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS {
|
||||
if self.cluster_open {
|
||||
// Close current cluster (it's a master with unknown size — we use known size)
|
||||
// Actually, for streaming we keep clusters open-ended. Just start a new one.
|
||||
}
|
||||
self.start_cluster(pts_ms)?;
|
||||
|
||||
// Add cue point at cluster start for keyframes (video track 0)
|
||||
if keyframe && track_idx == 0 {
|
||||
self.cues.push(CuePoint {
|
||||
timestamp_ms: pts_ms,
|
||||
track: track_idx + 1,
|
||||
cluster_pos: self.cluster_pos - self.segment_start,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write SimpleBlock
|
||||
let relative_ts = (pts_ms - self.cluster_ts_ms) as i16;
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
self.frame_count += 1;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish the MKV file: write Cues element.
|
||||
pub fn finish(mut self) -> io::Result<()> {
|
||||
// Close final cluster
|
||||
self.end_cluster()?;
|
||||
|
||||
// Write Cues
|
||||
if !self.cues.is_empty() {
|
||||
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
|
||||
for cue in &self.cues {
|
||||
let cp_pos = ebml::start_master(&mut self.writer, ebml::CUE_POINT)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_TIME, cue.timestamp_ms as u64)?;
|
||||
let ctp_pos = ebml::start_master(&mut self.writer, ebml::CUE_TRACK_POSITIONS)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_TRACK, cue.track as u64)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_CLUSTER_POSITION, cue.cluster_pos)?;
|
||||
ebml::end_master(&mut self.writer, ctp_pos)?;
|
||||
ebml::end_master(&mut self.writer, cp_pos)?;
|
||||
}
|
||||
ebml::end_master(&mut self.writer, cues_pos)?;
|
||||
}
|
||||
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill in a deferred codecPrivate for a track.
|
||||
/// Seeks back to the placeholder, writes the actual data, restores position.
|
||||
pub fn fill_codec_private(&mut self, track_idx: usize, data: &[u8]) -> io::Result<()> {
|
||||
if track_idx >= self.codec_private_filled.len() || self.codec_private_filled[track_idx] {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((pos, max_size)) = self.codec_private_slots[track_idx] {
|
||||
if data.len() > max_size {
|
||||
// Data too large for reserved space — can't fill in place
|
||||
// This shouldn't happen with 256 bytes reserved
|
||||
return Ok(());
|
||||
}
|
||||
let current = self.writer.stream_position()?;
|
||||
self.writer.seek(SeekFrom::Start(pos))?;
|
||||
// Rewrite: element ID + size + data + zero-pad remainder
|
||||
let mut padded = data.to_vec();
|
||||
padded.resize(max_size, 0);
|
||||
ebml::write_binary(&mut self.writer, ebml::CODEC_PRIVATE, &padded)?;
|
||||
self.writer.seek(SeekFrom::Start(current))?;
|
||||
self.codec_private_filled[track_idx] = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_cluster(&mut self, ts_ms: i64) -> io::Result<()> {
|
||||
// Close previous cluster if open
|
||||
if self.cluster_open {
|
||||
self.end_cluster()?;
|
||||
}
|
||||
self.cluster_pos = self.writer.stream_position()?;
|
||||
self.cluster_size_pos = ebml::start_master(&mut self.writer, ebml::CLUSTER)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CLUSTER_TIMESTAMP, ts_ms as u64)?;
|
||||
self.cluster_ts_ms = ts_ms;
|
||||
self.cluster_open = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn end_cluster(&mut self) -> io::Result<()> {
|
||||
if self.cluster_open {
|
||||
ebml::end_master(&mut self.writer, self.cluster_size_pos)?;
|
||||
self.cluster_open = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_simple_block(&mut self, track_num: usize, relative_ts: i16, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
// SimpleBlock: [track_number VINT] [relative_ts i16] [flags u8] [data]
|
||||
// Track number as EBML VINT
|
||||
let track_vint = if track_num < 0x80 {
|
||||
vec![(track_num as u8) | 0x80]
|
||||
} else {
|
||||
vec![0x40 | ((track_num >> 8) as u8), track_num as u8]
|
||||
};
|
||||
|
||||
let flags: u8 = if keyframe { 0x80 } else { 0x00 };
|
||||
|
||||
let block_size = track_vint.len() + 2 + 1 + data.len(); // vint + ts(2) + flags(1) + data
|
||||
ebml::write_id(&mut self.writer, ebml::SIMPLE_BLOCK)?;
|
||||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||||
self.writer.write_all(&track_vint)?;
|
||||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||||
self.writer.write_all(&[flags])?;
|
||||
self.writer.write_all(data)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
fn parse_resolution(s: &str) -> (u32, u32) {
|
||||
if s.contains("2160") { (3840, 2160) }
|
||||
else if s.contains("1080") { (1920, 1080) }
|
||||
else if s.contains("720") { (1280, 720) }
|
||||
else if s.contains("576") { (720, 576) }
|
||||
else if s.contains("480") { (720, 480) }
|
||||
else { (1920, 1080) }
|
||||
}
|
||||
|
||||
fn parse_sample_rate(s: &str) -> f64 {
|
||||
if s.contains("96") { 96000.0 }
|
||||
else if s.contains("192") { 192000.0 }
|
||||
else { 48000.0 }
|
||||
}
|
||||
|
||||
fn parse_channels(s: &str) -> u8 {
|
||||
if s.contains("7.1") { 8 }
|
||||
else if s.contains("5.1") { 6 }
|
||||
else if s.contains("stereo") || s.contains("2.0") { 2 }
|
||||
else if s.contains("mono") { 1 }
|
||||
else { 6 }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! MKV muxing pipeline.
|
||||
//!
|
||||
//! Provides BD transport stream → MKV remuxing via composable streams.
|
||||
//!
|
||||
//! The main type is `MkvStream` — wraps any `Write + Seek` output,
|
||||
//! receives raw BD-TS bytes via `write()`, outputs MKV.
|
||||
//!
|
||||
//! ```text
|
||||
//! disc.rip(title, MkvStream::new(file, &title))
|
||||
//! ```
|
||||
//!
|
||||
//! Components (for advanced use):
|
||||
//! - `ts`: BD transport stream demuxer (192-byte packets → PES frames)
|
||||
//! - `ebml`: EBML write primitives for Matroska container
|
||||
//! - `mkv`: MKV muxer (tracks, clusters, blocks, cues)
|
||||
//! - `codec`: Elementary stream parsers (frame boundaries, codec headers)
|
||||
|
||||
pub mod ebml;
|
||||
pub mod ts;
|
||||
pub mod mkv;
|
||||
pub mod codec;
|
||||
pub mod lookahead;
|
||||
pub mod stream;
|
||||
|
||||
pub use stream::MkvStream;
|
||||
@@ -0,0 +1,218 @@
|
||||
//! MkvStream — a Write adapter that demuxes BD-TS and writes MKV.
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! let output = MkvStream::new(file)
|
||||
//! .title(&disc.titles[0])
|
||||
//! .max_buffer(20 * 1024 * 1024);
|
||||
//!
|
||||
//! disc.rip(0, output)?;
|
||||
//! ```
|
||||
|
||||
use std::io::{self, Write, Seek};
|
||||
use super::ts::{TsDemuxer, PesPacket};
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::codec::{self, CodecParser};
|
||||
use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE};
|
||||
use crate::disc::{Stream, Title};
|
||||
|
||||
/// Phase of the MkvStream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Phase {
|
||||
/// Collecting data in lookahead buffer, scanning for codec setup.
|
||||
Scanning,
|
||||
/// Header written, streaming directly to muxer.
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// MKV output stream. Implements `Write`.
|
||||
pub struct MkvStream<W: Write + Seek> {
|
||||
demuxer: TsDemuxer,
|
||||
muxer: Option<MkvMuxer<W>>,
|
||||
writer: Option<W>,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
tracks: Vec<MkvTrack>,
|
||||
title_name: String,
|
||||
duration_secs: f64,
|
||||
lookahead: LookaheadBuffer,
|
||||
phase: Phase,
|
||||
video_tracks_pending: usize,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MkvStream<W> {
|
||||
/// Create a new MkvStream wrapping an output writer.
|
||||
pub fn new(writer: W) -> Self {
|
||||
Self {
|
||||
demuxer: TsDemuxer::new(&[]),
|
||||
muxer: None,
|
||||
writer: Some(writer),
|
||||
parsers: Vec::new(),
|
||||
pid_to_track: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
title_name: String::new(),
|
||||
duration_secs: 0.0,
|
||||
lookahead: LookaheadBuffer::new(DEFAULT_LOOKAHEAD_SIZE),
|
||||
phase: Phase::Scanning,
|
||||
video_tracks_pending: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the title metadata (streams, duration, name). Returns self.
|
||||
pub fn title(mut self, title: &Title) -> Self {
|
||||
let mut pids = Vec::new();
|
||||
|
||||
for stream in &title.streams {
|
||||
let (pid, track, parser) = match stream {
|
||||
Stream::Video(v) => {
|
||||
self.video_tracks_pending += 1;
|
||||
(v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec))
|
||||
}
|
||||
Stream::Audio(a) => (a.pid, MkvTrack::audio(a), codec::parser_for_codec(a.codec)),
|
||||
Stream::Subtitle(s) => (s.pid, MkvTrack::subtitle(s), codec::parser_for_codec(s.codec)),
|
||||
};
|
||||
let track_idx = self.tracks.len();
|
||||
pids.push(pid);
|
||||
self.pid_to_track.push((pid, track_idx));
|
||||
self.parsers.push((pid, parser));
|
||||
self.tracks.push(track);
|
||||
}
|
||||
|
||||
self.demuxer = TsDemuxer::new(&pids);
|
||||
self.title_name = title.playlist.clone();
|
||||
self.duration_secs = title.duration_secs;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the lookahead buffer size in bytes. Default 5 MB. Returns self.
|
||||
pub fn max_buffer(mut self, size: usize) -> Self {
|
||||
self.lookahead = LookaheadBuffer::new(size);
|
||||
self
|
||||
}
|
||||
|
||||
/// Finalize the MKV file — close cluster, write cues.
|
||||
pub fn finish(mut self) -> io::Result<()> {
|
||||
if let Some(ref mut muxer) = self.muxer {
|
||||
let remaining = self.demuxer.flush();
|
||||
for pes in &remaining {
|
||||
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
if let Some(muxer) = self.muxer {
|
||||
muxer.finish()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_codec_private(&mut self) -> bool {
|
||||
if self.video_tracks_pending == 0 {
|
||||
return true;
|
||||
}
|
||||
for (pid, parser) in &self.parsers {
|
||||
if let Some(cp) = parser.codec_private() {
|
||||
if let Some((_, track_idx)) = self.pid_to_track.iter().find(|(p, _)| p == pid) {
|
||||
if self.tracks[*track_idx].codec_private.is_none() {
|
||||
self.tracks[*track_idx].codec_private = Some(cp);
|
||||
self.video_tracks_pending -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.video_tracks_pending == 0
|
||||
}
|
||||
|
||||
fn start_streaming(&mut self) -> io::Result<()> {
|
||||
let writer = self.writer.take().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::Other, "writer already consumed")
|
||||
})?;
|
||||
|
||||
let muxer = MkvMuxer::new(writer, &self.tracks, Some(&self.title_name), self.duration_secs)?;
|
||||
self.muxer = Some(muxer);
|
||||
self.phase = Phase::Streaming;
|
||||
|
||||
// Re-parse and write buffered data
|
||||
let buffered = self.lookahead.drain();
|
||||
if !buffered.is_empty() {
|
||||
let pids: Vec<u16> = self.pid_to_track.iter().map(|(pid, _)| *pid).collect();
|
||||
let mut temp_demuxer = TsDemuxer::new(&pids);
|
||||
let mut packets = temp_demuxer.feed(&buffered);
|
||||
packets.extend(temp_demuxer.flush());
|
||||
|
||||
if let Some(ref mut muxer) = self.muxer {
|
||||
for pes in &packets {
|
||||
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_one_pes(
|
||||
pid_to_track: &[(u16, usize)],
|
||||
parsers: &mut [(u16, Box<dyn CodecParser>)],
|
||||
muxer: &mut MkvMuxer<W>,
|
||||
pes: &PesPacket,
|
||||
) -> io::Result<()> {
|
||||
let track_idx = match pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
|
||||
Some((_, idx)) => *idx,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let parser = match parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
Some((_, p)) => p,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let frames = parser.parse(pes);
|
||||
for frame in frames {
|
||||
muxer.write_frame(track_idx, frame.pts_ns, frame.keyframe, &frame.data)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Write for MkvStream<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.phase {
|
||||
Phase::Scanning => {
|
||||
// Parse for codec info
|
||||
let packets = self.demuxer.feed(buf);
|
||||
for pes in &packets {
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pes.pid) {
|
||||
let _ = parser.parse(pes);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to buffer
|
||||
let state = self.lookahead.push(buf);
|
||||
|
||||
// Check if we have everything
|
||||
if self.check_codec_private() {
|
||||
self.lookahead.mark_ready();
|
||||
self.start_streaming()?;
|
||||
return Ok(buf.len());
|
||||
}
|
||||
|
||||
match state {
|
||||
LookaheadState::Collecting => Ok(buf.len()),
|
||||
LookaheadState::Overflow => Err(io::Error::new(
|
||||
io::ErrorKind::OutOfMemory,
|
||||
"MKV lookahead buffer overflow — no codec data found within buffer limit",
|
||||
)),
|
||||
LookaheadState::Ready => Ok(buf.len()),
|
||||
}
|
||||
}
|
||||
Phase::Streaming => {
|
||||
let packets = self.demuxer.feed(buf);
|
||||
if let Some(ref mut muxer) = self.muxer {
|
||||
for pes in &packets {
|
||||
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
//! BD Transport Stream demuxer.
|
||||
//!
|
||||
//! Blu-ray uses 192-byte TS packets (not standard 188):
|
||||
//! - 4-byte TP_extra_header (arrival timestamp + copy permission)
|
||||
//! - 188-byte standard MPEG-TS packet
|
||||
//!
|
||||
//! This demuxer extracts PES packets from selected PIDs, with PTS/DTS timestamps.
|
||||
|
||||
/// BD transport stream packet size (4-byte extra header + 188-byte TS).
|
||||
const BD_TS_PACKET_SIZE: usize = 192;
|
||||
|
||||
/// Standard TS packet size.
|
||||
const TS_PACKET_SIZE: usize = 188;
|
||||
|
||||
/// TS sync byte.
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
|
||||
/// A reassembled PES packet with timestamp info.
|
||||
#[derive(Debug)]
|
||||
pub struct PesPacket {
|
||||
/// MPEG-TS PID this packet belongs to.
|
||||
pub pid: u16,
|
||||
/// Presentation timestamp in 90kHz ticks (if present).
|
||||
pub pts: Option<i64>,
|
||||
/// Decode timestamp in 90kHz ticks (if present).
|
||||
pub dts: Option<i64>,
|
||||
/// Elementary stream data (video frame, audio frame, subtitle segment, etc.).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Per-PID PES reassembly state.
|
||||
struct PesAssembler {
|
||||
pid: u16,
|
||||
buffer: Vec<u8>,
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl PesAssembler {
|
||||
fn new(pid: u16) -> Self {
|
||||
Self {
|
||||
pid,
|
||||
buffer: Vec::with_capacity(256 * 1024),
|
||||
pts: None,
|
||||
dts: None,
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new PES packet. Returns the completed previous packet (if any).
|
||||
fn start(&mut self, pts: Option<i64>, dts: Option<i64>) -> Option<PesPacket> {
|
||||
let completed = if self.active && !self.buffer.is_empty() {
|
||||
Some(PesPacket {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(256 * 1024)),
|
||||
})
|
||||
} else {
|
||||
self.buffer.clear();
|
||||
None
|
||||
};
|
||||
self.pts = pts;
|
||||
self.dts = dts;
|
||||
self.active = true;
|
||||
completed
|
||||
}
|
||||
|
||||
/// Append payload data to the current PES packet.
|
||||
fn push(&mut self, data: &[u8]) {
|
||||
if self.active {
|
||||
self.buffer.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush remaining data as a PES packet.
|
||||
fn flush(&mut self) -> Option<PesPacket> {
|
||||
if self.active && !self.buffer.is_empty() {
|
||||
self.active = false;
|
||||
Some(PesPacket {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::take(&mut self.buffer),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BD Transport Stream demuxer.
|
||||
pub struct TsDemuxer {
|
||||
assemblers: Vec<PesAssembler>,
|
||||
pid_index: [i16; 8192], // PID → index into assemblers, -1 = not tracked
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
/// Create a new demuxer tracking the given PIDs.
|
||||
pub fn new(pids: &[u16]) -> Self {
|
||||
let mut pid_index = [-1i16; 8192];
|
||||
let mut assemblers = Vec::with_capacity(pids.len());
|
||||
for (i, &pid) in pids.iter().enumerate() {
|
||||
pid_index[pid as usize] = i as i16;
|
||||
assemblers.push(PesAssembler::new(pid));
|
||||
}
|
||||
Self { assemblers, pid_index }
|
||||
}
|
||||
|
||||
/// Feed a chunk of BD transport stream data (must be aligned to 192-byte packets).
|
||||
/// Returns completed PES packets.
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
|
||||
let mut completed = Vec::new();
|
||||
let mut offset = 0;
|
||||
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
let packet = &data[offset..offset + BD_TS_PACKET_SIZE];
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
|
||||
// Skip 4-byte TP_extra_header, check sync byte
|
||||
if packet[4] != SYNC_BYTE {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = &packet[4..]; // 188-byte standard TS packet
|
||||
|
||||
// Parse TS header
|
||||
let pid = (((ts[1] & 0x1F) as u16) << 8) | ts[2] as u16;
|
||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||
let adaptation = (ts[3] >> 4) & 0x03;
|
||||
|
||||
// Check if we're tracking this PID
|
||||
let idx = self.pid_index[pid as usize];
|
||||
if idx < 0 {
|
||||
continue;
|
||||
}
|
||||
let asm = &mut self.assemblers[idx as usize];
|
||||
|
||||
// Find payload start (skip adaptation field if present)
|
||||
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
|
||||
// Adaptation field present
|
||||
let af_len = ts[4] as usize;
|
||||
5 + af_len
|
||||
} else {
|
||||
4
|
||||
};
|
||||
|
||||
if payload_start >= TS_PACKET_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
// No payload
|
||||
if adaptation == 0x02 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = &ts[payload_start..];
|
||||
|
||||
if pusi {
|
||||
// New PES packet starts here — parse PES header
|
||||
let (pts, dts, pes_data_start) = parse_pes_header(payload);
|
||||
if let Some(prev) = asm.start(pts, dts) {
|
||||
completed.push(prev);
|
||||
}
|
||||
if pes_data_start < payload.len() {
|
||||
asm.push(&payload[pes_data_start..]);
|
||||
}
|
||||
} else {
|
||||
// Continuation of current PES packet
|
||||
asm.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
completed
|
||||
}
|
||||
|
||||
/// Flush all assemblers, returning any remaining PES packets.
|
||||
pub fn flush(&mut self) -> Vec<PesPacket> {
|
||||
let mut completed = Vec::new();
|
||||
for asm in &mut self.assemblers {
|
||||
if let Some(pkt) = asm.flush() {
|
||||
completed.push(pkt);
|
||||
}
|
||||
}
|
||||
completed
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PES packet header, extracting PTS and DTS.
|
||||
/// Returns (pts, dts, offset_to_elementary_stream_data).
|
||||
fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
// PES packet: 00 00 01 [stream_id] [length:2] [flags...]
|
||||
if data.len() < 9 || data[0] != 0x00 || data[1] != 0x00 || data[2] != 0x01 {
|
||||
return (None, None, 0);
|
||||
}
|
||||
|
||||
let stream_id = data[3];
|
||||
|
||||
// Some stream IDs don't have the standard PES header extension
|
||||
// (program_stream_map, padding, private_stream_2, ECM, EMM, etc.)
|
||||
if stream_id == 0xBC || stream_id == 0xBE || stream_id == 0xBF
|
||||
|| stream_id == 0xF0 || stream_id == 0xF1 || stream_id == 0xFF
|
||||
{
|
||||
return (None, None, 6);
|
||||
}
|
||||
|
||||
// Standard PES header: [6] = flags1, [7] = flags2, [8] = header_data_length
|
||||
if data.len() < 9 {
|
||||
return (None, None, 6);
|
||||
}
|
||||
|
||||
let pts_dts_flags = (data[7] >> 6) & 0x03;
|
||||
let header_data_len = data[8] as usize;
|
||||
let data_start = 9 + header_data_len;
|
||||
|
||||
let mut pts = None;
|
||||
let mut dts = None;
|
||||
|
||||
if pts_dts_flags >= 2 && data.len() >= 14 {
|
||||
pts = Some(parse_timestamp(&data[9..14]));
|
||||
}
|
||||
if pts_dts_flags == 3 && data.len() >= 19 {
|
||||
dts = Some(parse_timestamp(&data[14..19]));
|
||||
}
|
||||
|
||||
(pts, dts, data_start)
|
||||
}
|
||||
|
||||
/// Parse a 5-byte PTS/DTS timestamp (33 bits in 90kHz).
|
||||
fn parse_timestamp(data: &[u8]) -> i64 {
|
||||
let b0 = data[0] as i64;
|
||||
let b1 = data[1] as i64;
|
||||
let b2 = data[2] as i64;
|
||||
let b3 = data[3] as i64;
|
||||
let b4 = data[4] as i64;
|
||||
|
||||
((b0 >> 1) & 0x07) << 30
|
||||
| b1 << 22
|
||||
| (b2 >> 1) << 15
|
||||
| b3 << 7
|
||||
| b4 >> 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_timestamp() {
|
||||
// Example: PTS = 0 → encoded as 21 00 01 00 01
|
||||
let data = [0x21, 0x00, 0x01, 0x00, 0x01];
|
||||
assert_eq!(parse_timestamp(&data), 0);
|
||||
|
||||
// Example: PTS = 90000 (1 second at 90kHz)
|
||||
// Manual encoding: 33 bits = 0x00015F90
|
||||
// This is just a sanity check that the parser doesn't crash
|
||||
let data2 = [0x21, 0x00, 0x07, 0xE9, 0x01]; // approximate
|
||||
let pts = parse_timestamp(&data2);
|
||||
assert!(pts >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_demuxer_empty() {
|
||||
let mut demux = TsDemuxer::new(&[0x1011]);
|
||||
let result = demux.feed(&[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
+11
-57
@@ -1,66 +1,20 @@
|
||||
//! Platform-specific implementations of raw disc access commands.
|
||||
//!
|
||||
//! Each chipset family (MT1959, Pioneer) implements the Platform trait.
|
||||
//! accessed via SCSI READ BUFFER with platform-specific mode and buffer ID.
|
||||
//! Platform-specific drive initialization and disc probing.
|
||||
|
||||
pub mod mt1959;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::ScsiTransport;
|
||||
|
||||
/// Platform trait — raw disc access commands implemented per chipset.
|
||||
///
|
||||
/// Command handlers accessed via READ BUFFER:
|
||||
pub trait Platform {
|
||||
///
|
||||
/// Sends the platform-specific READ BUFFER CDB and verifies
|
||||
/// the response signature bytes.
|
||||
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
pub(crate) trait PlatformDriver {
|
||||
/// Unlock drive + upload firmware if needed.
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Performs a primary READ BUFFER for the configuration data,
|
||||
/// followed by a secondary 4-byte status read.
|
||||
fn read_config(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>>;
|
||||
/// Calibrate drive for this disc. Probes disc surface so the drive's
|
||||
/// firmware learns the optimal speed for each region. After probing
|
||||
/// the drive manages per-zone speeds internally — the host just reads
|
||||
/// at max speed.
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// Handlers 2-3: Read hardware register.
|
||||
///
|
||||
/// `index` selects which register offset from the profile to use.
|
||||
/// Returns 16 bytes of register data extracted from a 36-byte response.
|
||||
fn read_register(&mut self, scsi: &mut dyn ScsiTransport, index: u8) -> Result<[u8; 16]>;
|
||||
|
||||
///
|
||||
/// Probes the disc surface via READ BUFFER sub-commands to build
|
||||
/// a 64-entry speed lookup table for optimal read performance.
|
||||
/// Issues SET CD SPEED at maximum after calibration completes.
|
||||
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Periodic command to maintain the raw access session.
|
||||
fn keepalive(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Verifies the response signature and returns 16 bytes of
|
||||
/// feature/status data.
|
||||
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus>;
|
||||
|
||||
///
|
||||
/// Sends a READ BUFFER command with dynamic sub-command, address,
|
||||
/// and length. Used for disc structure reads and feature queries.
|
||||
fn probe(&mut self, scsi: &mut dyn ScsiTransport, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>>;
|
||||
|
||||
///
|
||||
/// Looks up the LBA in the speed table, issues SET CD SPEED,
|
||||
/// then performs a READ(10) with the raw read flag (0x08).
|
||||
fn read_sectors(&mut self, scsi: &mut dyn ScsiTransport, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
|
||||
|
||||
fn timing(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// Check if raw disc access mode is currently enabled.
|
||||
fn is_unlocked(&self) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveStatus {
|
||||
pub unlocked: bool,
|
||||
pub features: [u8; 16],
|
||||
/// True after successful init().
|
||||
fn is_ready(&self) -> bool;
|
||||
}
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
//! MT1959 platform implementation — covers all LG/ASUS MediaTek drives.
|
||||
//!
|
||||
//! Two variants share this code:
|
||||
//! MT1959-A: mode=0x01, buffer_id=0x44 (handlers 0-9)
|
||||
//! MT1959-B: mode=0x02, buffer_id=0x77 (handlers 4-9, 0-3 are no-ops)
|
||||
//!
|
||||
//! The logic is identical between A and B — only the SCSI READ BUFFER
|
||||
//! mode and buffer ID differ. Per-drive data (signature, register offsets)
|
||||
//! comes from the profile.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
use super::{Platform, DriveStatus};
|
||||
|
||||
/// MT1959 driver state.
|
||||
pub struct Mt1959 {
|
||||
profile: DriveProfile,
|
||||
mode: u8,
|
||||
buffer_id: u8,
|
||||
unlocked: bool,
|
||||
speed_table: [u16; 64],
|
||||
calibrated: bool,
|
||||
}
|
||||
|
||||
impl Mt1959 {
|
||||
pub fn new(profile: DriveProfile) -> Self {
|
||||
let mode = profile.unlock_mode;
|
||||
let buffer_id = profile.unlock_buf_id;
|
||||
Mt1959 {
|
||||
profile,
|
||||
mode,
|
||||
buffer_id,
|
||||
unlocked: false,
|
||||
speed_table: [0u16; 64],
|
||||
calibrated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER CDB for this platform's mode and buffer ID.
|
||||
fn read_buffer_cdb(&self, offset: u32, length: u32) -> [u8; 10] {
|
||||
scsi::build_read_buffer(self.mode, self.buffer_id, offset, length)
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER CDB with a sub-command byte in CDB[3].
|
||||
fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
|
||||
[
|
||||
0x3C,
|
||||
self.mode,
|
||||
self.buffer_id,
|
||||
sub_cmd,
|
||||
(address >> 8) as u8,
|
||||
address as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
length,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
///
|
||||
/// 1. Send READ BUFFER(mode, buffer_id, offset=0, length=64)
|
||||
/// 2. Check response[0:4] matches the profile signature
|
||||
/// 3. Check response[12:16] matches the verification bytes (0x4D4D6B76)
|
||||
fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 64]> {
|
||||
let cdb = self.read_buffer_cdb(0, 64);
|
||||
let mut response = [0u8; 64];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
// Check signature at response[0:4]
|
||||
let got_sig: [u8; 4] = response[0..4].try_into().unwrap();
|
||||
if got_sig != self.profile.signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.signature,
|
||||
got: got_sig,
|
||||
});
|
||||
}
|
||||
|
||||
// Check verification bytes at response[12:16]
|
||||
if &response[12..16] != self.profile.verify.as_slice() {
|
||||
return Err(Error::UnlockFailed { detail: format!(
|
||||
"verify mismatch at [12:16]: {:02x}{:02x}{:02x}{:02x}",
|
||||
response[12], response[13], response[14], response[15]
|
||||
) });
|
||||
}
|
||||
|
||||
self.unlocked = true;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Ensure raw disc access is active, re-enabling if needed.
|
||||
fn ensure_unlocked(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked {
|
||||
self.do_unlock(scsi)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-operation validation with retry.
|
||||
///
|
||||
/// Sends a short READ BUFFER probe, retries up to 5 times to confirm
|
||||
/// the drive is still responding to commands.
|
||||
fn validate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
for _attempt in 0..5 {
|
||||
let cdb = self.read_buffer_cdb(0, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError {
|
||||
opcode: 0x3C,
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up optimal read speed for a given LBA from the calibration table.
|
||||
fn lookup_speed(&self, lba: u32) -> u16 {
|
||||
if !self.calibrated {
|
||||
return 0;
|
||||
}
|
||||
let mut best_speed = 0u16;
|
||||
let mut best_diff = u32::MAX;
|
||||
for &entry in &self.speed_table {
|
||||
if entry == 0 {
|
||||
continue;
|
||||
}
|
||||
let entry_lba = entry as u32;
|
||||
let diff = if lba > entry_lba { lba - entry_lba } else { entry_lba - lba };
|
||||
if diff < best_diff {
|
||||
best_diff = diff;
|
||||
best_speed = entry;
|
||||
}
|
||||
}
|
||||
best_speed
|
||||
}
|
||||
|
||||
/// Send SET CD SPEED command.
|
||||
fn set_cd_speed(&self, scsi: &mut dyn ScsiTransport, speed: u16) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(speed);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform for Mt1959 {
|
||||
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
self.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Primary read: 0x760 (1888) bytes of configuration data.
|
||||
/// Secondary read: 4-byte status appended to the result.
|
||||
fn read_config(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
// Primary config read: 0x760 = 1888 bytes
|
||||
let cdb = self.read_buffer_cdb(0, 0x760);
|
||||
let mut buf = vec![0u8; 0x760];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 30_000)?;
|
||||
buf.truncate(result.bytes_transferred);
|
||||
|
||||
// Secondary: 4-byte status read
|
||||
let cdb2 = self.read_buffer_cdb(0, 4);
|
||||
let mut status = [0u8; 4];
|
||||
scsi.execute(&cdb2, DataDirection::FromDevice, &mut status, 5_000)?;
|
||||
|
||||
buf.extend_from_slice(&status);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Handlers 2-3: Read hardware register at the profile-specified offset.
|
||||
///
|
||||
/// Reads 36 bytes via READ BUFFER and extracts bytes [4:20] as the
|
||||
/// 16-byte register value.
|
||||
fn read_register(&mut self, scsi: &mut dyn ScsiTransport, index: u8) -> Result<[u8; 16]> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
self.validate(scsi)?;
|
||||
|
||||
let offset = *self.profile.register_offsets.get(index as usize)
|
||||
.ok_or_else(|| Error::ProfileNotFound {
|
||||
vendor_id: self.profile.vendor_id.clone(),
|
||||
product_revision: self.profile.product_revision.clone(),
|
||||
vendor_specific: format!("register index {} out of range", index),
|
||||
})?;
|
||||
|
||||
let cdb = scsi::build_read_buffer(self.mode, self.buffer_id, offset, 36);
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&response[4..20]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
///
|
||||
/// Scans disc surface addresses via READ BUFFER sub-command 0x14 to
|
||||
/// build a 64-entry speed lookup table. Issues SET CD SPEED(max) when done.
|
||||
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Initial probe: READ BUFFER sub_cmd=0x12
|
||||
let cdb = self.read_buffer_sub(0x12, 0, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000);
|
||||
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Clear speed table
|
||||
self.speed_table = [0u16; 64];
|
||||
|
||||
// Scan disc surface — probe addresses up to 0x10000, 256 at a time
|
||||
let mut table_idx = 0usize;
|
||||
let mut addr: u32 = 0;
|
||||
while addr < 0x10000 && table_idx < 64 {
|
||||
let cdb = self.read_buffer_sub(0x14, addr as u16, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) {
|
||||
Ok(r) if r.bytes_transferred == 4 => {
|
||||
let val = resp[0];
|
||||
if val > 0 {
|
||||
let speed_entry = ((resp[0] as u16) << 8) | (resp[1] as u16);
|
||||
if speed_entry > 0 {
|
||||
self.speed_table[table_idx] = speed_entry;
|
||||
table_idx += 1;
|
||||
}
|
||||
}
|
||||
addr += 256;
|
||||
}
|
||||
_ => {
|
||||
addr += 256;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set max speed after calibration
|
||||
self.set_cd_speed(scsi, 0xFFFF)?;
|
||||
|
||||
self.calibrated = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keepalive(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Sends READ BUFFER with sub-command 0x13, reads 36 bytes.
|
||||
/// Checks signature at [0:4], returns feature data from [4:20].
|
||||
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
self.validate(scsi)?;
|
||||
|
||||
// READ BUFFER with sub_cmd=0x13, 36 bytes response
|
||||
let cdb = self.read_buffer_sub(0x13, 0, 36);
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
// Verify response signature
|
||||
let got_sig = u32::from_be_bytes(response[0..4].try_into().unwrap());
|
||||
let expected_sig = u32::from_le_bytes(self.profile.signature);
|
||||
|
||||
let mut features = [0u8; 16];
|
||||
features.copy_from_slice(&response[4..20]);
|
||||
|
||||
Ok(DriveStatus {
|
||||
unlocked: got_sig == expected_sig,
|
||||
features,
|
||||
})
|
||||
}
|
||||
|
||||
fn probe(&mut self, scsi: &mut dyn ScsiTransport, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
|
||||
let cdb = [
|
||||
0x3C,
|
||||
self.mode,
|
||||
self.buffer_id,
|
||||
sub_cmd,
|
||||
(address >> 16) as u8,
|
||||
(address >> 8) as u8,
|
||||
address as u8,
|
||||
(length >> 16) as u8,
|
||||
(length >> 8) as u8,
|
||||
length as u8,
|
||||
];
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 30_000)?;
|
||||
buf.truncate(result.bytes_transferred);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
///
|
||||
/// Looks up the LBA in the speed table, issues SET CD SPEED if calibrated,
|
||||
/// then performs READ(10) with the raw read flag (0x08).
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
) -> Result<usize> {
|
||||
if !self.unlocked {
|
||||
return Err(Error::NotUnlocked);
|
||||
}
|
||||
|
||||
// Speed optimization from calibration
|
||||
if self.calibrated {
|
||||
let speed = self.lookup_speed(lba);
|
||||
if speed > 0 {
|
||||
let _ = self.set_cd_speed(scsi, speed);
|
||||
}
|
||||
}
|
||||
|
||||
// READ(10) with raw flag 0x08
|
||||
let cdb = scsi::build_read10_raw(lba, count);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 30_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
fn timing(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_unlocked(&self) -> bool {
|
||||
self.unlocked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! MT1959 platform — shared logic for both variants.
|
||||
|
||||
mod variant_a;
|
||||
mod variant_b;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
use super::PlatformDriver;
|
||||
|
||||
// ── Variant constants ──────────────────────────────────────────────────
|
||||
// Every vendor command: 3C [mode] [buffer_id] [sub_cmd] [addr] ...
|
||||
const MODE_A: u8 = 0x01;
|
||||
const MODE_B: u8 = 0x02;
|
||||
const BUFFER_ID_A: u8 = 0x44;
|
||||
const BUFFER_ID_B: u8 = 0x77;
|
||||
|
||||
// ── SCSI opcodes ──────────────────────────────────────────────────────
|
||||
const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
|
||||
// ── Sub-commands (shared A/B) ─────────────────────────────────────────
|
||||
const SUB_CMD_UNLOCK: u8 = 0x00;
|
||||
const SUB_CMD_INIT: u8 = 0x12;
|
||||
const SUB_CMD_PROBE: u8 = 0x14;
|
||||
const UNLOCK_RESPONSE_SIZE: u8 = 64;
|
||||
const VALIDATE_RESPONSE_SIZE: u8 = 4;
|
||||
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
|
||||
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
|
||||
|
||||
// ── Init address (per disc type) ──────────────────────────────────────
|
||||
const INIT_ADDR_BD: u16 = 0x0100;
|
||||
const INIT_ADDR_UHD: u16 = 0x0200;
|
||||
|
||||
// ── Probe scan ranges ─────────────────────────────────────────────────
|
||||
const PROBE_COARSE_END: u16 = 0x5800;
|
||||
const PROBE_FINE_END: u32 = 0x10000;
|
||||
const PROBE_STEP: u16 = 0x0100;
|
||||
const PROBE_RESPONSE_SIZE: u8 = 4;
|
||||
|
||||
// ── Disc type threshold ───────────────────────────────────────────────
|
||||
const UHD_SECTOR_THRESHOLD: u32 = 25_000_000; // ~50 GB
|
||||
const READ_CAPACITY_RESPONSE_SIZE: usize = 8;
|
||||
|
||||
pub struct Mt1959 {
|
||||
pub(crate) profile: DriveProfile,
|
||||
pub(crate) mode: u8,
|
||||
pub(crate) buffer_id: u8,
|
||||
pub(crate) unlocked: bool,
|
||||
probed: bool,
|
||||
}
|
||||
|
||||
impl Mt1959 {
|
||||
pub fn new(profile: DriveProfile, is_variant_b: bool) -> Self {
|
||||
let (mode, buffer_id) = if is_variant_b {
|
||||
(MODE_B, BUFFER_ID_B)
|
||||
} else {
|
||||
(MODE_A, BUFFER_ID_A)
|
||||
};
|
||||
Mt1959 {
|
||||
profile, mode, buffer_id,
|
||||
unlocked: false,
|
||||
probed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCSI helpers (shared by both variants) ─────────────────────────
|
||||
|
||||
pub(crate) fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
|
||||
[
|
||||
SCSI_READ_BUFFER, self.mode, self.buffer_id, sub_cmd,
|
||||
(address >> 8) as u8, address as u8,
|
||||
0x00, 0x00, length, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn read_buffer_probe(
|
||||
&self, scsi: &mut dyn ScsiTransport,
|
||||
sub_cmd: u8, address: u16, buf: &mut [u8], expected: usize,
|
||||
) -> Result<usize> {
|
||||
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
if result.bytes_transferred != expected {
|
||||
return Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
pub(crate) fn set_cd_speed_max(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(0xFFFF);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Unlock (shared) ────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
SUB_CMD_UNLOCK, 0x00, 0x00,
|
||||
0x00, 0x00, UNLOCK_RESPONSE_SIZE, 0x00,
|
||||
];
|
||||
let mut response = vec![0u8; UNLOCK_RESPONSE_SIZE as usize];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
if response.len() >= 4 && response[0..4] != self.profile.signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.signature,
|
||||
got: response[0..4].try_into().unwrap_or([0; 4]),
|
||||
});
|
||||
}
|
||||
|
||||
if response.len() >= FIRMWARE_ACTIVE_OFFSET + 4
|
||||
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
|
||||
self.unlocked = true;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn validate(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
for _attempt in 0..5 {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
SUB_CMD_UNLOCK, 0x00, 0x00,
|
||||
0x00, 0x00, VALIDATE_RESPONSE_SIZE, 0x00,
|
||||
];
|
||||
let mut resp = [0u8; 4];
|
||||
if scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 })
|
||||
}
|
||||
|
||||
// ── Init (unlock + firmware) ───────────────────────────────────────
|
||||
|
||||
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let mut unlocked = false;
|
||||
for _attempt in 0..6 {
|
||||
match self.do_unlock(scsi) {
|
||||
Ok(_) => { unlocked = true; break; }
|
||||
Err(Error::SignatureMismatch { .. }) => {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Err(_) => {
|
||||
let ok = if self.mode == MODE_A {
|
||||
variant_a::load_firmware(self, scsi).is_ok()
|
||||
} else {
|
||||
variant_b::load_firmware(self, scsi).is_ok()
|
||||
};
|
||||
if ok { unlocked = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unlocked {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Probe disc ─────────────────────────────────────────────────────
|
||||
|
||||
/// Probe the disc surface so the drive firmware learns optimal speeds
|
||||
/// per region. Two passes, then SET_CD_SPEED(max). After this the
|
||||
/// drive manages per-zone speeds internally.
|
||||
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
|
||||
// Detect disc type from capacity to select probe mode.
|
||||
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
|
||||
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
|
||||
// Verified from MakeMKV strace: BD and UHD use different init addresses.
|
||||
let cap_cdb = [SCSI_READ_CAPACITY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut cap_buf = [0u8; READ_CAPACITY_RESPONSE_SIZE];
|
||||
let disc_sectors = if scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000).is_ok() {
|
||||
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]) + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let init_addr = if disc_sectors > UHD_SECTOR_THRESHOLD { INIT_ADDR_UHD } else { INIT_ADDR_BD };
|
||||
let mut init_resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
let _ = self.read_buffer_probe(scsi, SUB_CMD_INIT, init_addr, &mut init_resp, PROBE_RESPONSE_SIZE as usize);
|
||||
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Pass 1: coarse scan
|
||||
let mut addr: u16 = 0;
|
||||
while addr < PROBE_COARSE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self.read_buffer_probe(scsi, SUB_CMD_PROBE, addr, &mut resp, PROBE_RESPONSE_SIZE as usize).is_err() {
|
||||
return Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
addr = addr.wrapping_add(PROBE_STEP);
|
||||
}
|
||||
|
||||
// Pass 2: fine scan
|
||||
let mut addr: u32 = 0;
|
||||
while addr < PROBE_FINE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self.read_buffer_probe(scsi, SUB_CMD_PROBE, addr as u16, &mut resp, PROBE_RESPONSE_SIZE as usize).is_err() {
|
||||
break;
|
||||
}
|
||||
addr += PROBE_STEP as u32;
|
||||
}
|
||||
|
||||
// Set max speed — drive manages zones from here
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
self.probed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── PlatformDriver trait ───────────────────────────────────────────────
|
||||
|
||||
impl PlatformDriver for Mt1959 {
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if self.unlocked { return Ok(()); }
|
||||
self.run_init(scsi)
|
||||
}
|
||||
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked { self.run_init(scsi)?; }
|
||||
if self.probed { return Ok(()); }
|
||||
self.run_probe(scsi)
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.unlocked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! MT1959 variant A firmware upload.
|
||||
//!
|
||||
//! WRITE_BUFFER (0x3B) → verify READ_BUFFER (0x45) → unlock × 2
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use super::Mt1959;
|
||||
|
||||
const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
const VERIFY_BUFFER_ID: u8 = 0x45;
|
||||
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Upload firmware via WRITE_BUFFER
|
||||
let len = firmware.len();
|
||||
let cdb = [
|
||||
SCSI_WRITE_BUFFER, 0x06, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(len >> 16) as u8, (len >> 8) as u8, len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware.clone();
|
||||
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Verify firmware loaded (non-fatal — different buffer_id 0x45)
|
||||
let verify_cdb = [super::SCSI_READ_BUFFER, super::MODE_A, VERIFY_BUFFER_ID, 0x00, 0x00, 0x00, 0x00, 0x00, super::VALIDATE_RESPONSE_SIZE, 0x00];
|
||||
let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize];
|
||||
let _ = scsi.execute(&verify_cdb, DataDirection::FromDevice, &mut verify_resp, 5_000);
|
||||
|
||||
// Double unlock after firmware upload
|
||||
mt.do_unlock(scsi)?;
|
||||
mt.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! MT1959 variant B firmware upload.
|
||||
//!
|
||||
//! MODE SELECT (0x55) → read metadata → WRITE_BUFFER → vendor verify (0xF1) → unlock × 5+1
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use super::Mt1959;
|
||||
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
const FIRMWARE_MAX_SIZE: usize = 0x9C0;
|
||||
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Step 1: Upload firmware via MODE SELECT
|
||||
let write_len = FIRMWARE_MAX_SIZE.min(firmware.len());
|
||||
let mode_select_cdb = [
|
||||
SCSI_MODE_SELECT, 0x10, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(write_len >> 16) as u8, (write_len >> 8) as u8, write_len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware[..write_len].to_vec();
|
||||
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000)
|
||||
let read_meta_cdb = [SCSI_READ_BUFFER, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut meta_resp = [0u8; 16];
|
||||
let _ = scsi.execute(&read_meta_cdb, DataDirection::FromDevice, &mut meta_resp, 5_000);
|
||||
|
||||
// Step 3: Write extra firmware data (all zeros)
|
||||
let write_extra_cdb = [SCSI_WRITE_BUFFER, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut data2 = FIRMWARE_EXTRA.to_vec();
|
||||
let _ = scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000);
|
||||
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI)
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&VENDOR_VERIFY, DataDirection::None, &mut dummy, 5_000);
|
||||
|
||||
// Step 5: Unlock retries (up to 5, then final attempt)
|
||||
for _attempt in 0..5 {
|
||||
if mt.do_unlock(scsi).is_ok() {
|
||||
let _ = mt.do_unlock(scsi);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
mt.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
+84
-240
@@ -1,300 +1,144 @@
|
||||
//! Drive profile loading and matching.
|
||||
//!
|
||||
//! Each supported drive has a profile containing the SCSI command
|
||||
//! parameters needed to enable raw disc access mode. Profiles are
|
||||
//! loaded from JSON files so new drives can be added without rebuilding.
|
||||
|
||||
use serde::Deserialize;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Per-drive profile containing SCSI parameters for raw disc access.
|
||||
/// Top-level profiles file — keyed by chipset + variant.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProfilesFile {
|
||||
#[serde(default)]
|
||||
pub mt1959_a: Vec<DriveProfile>,
|
||||
#[serde(default)]
|
||||
pub mt1959_b: Vec<DriveProfile>,
|
||||
#[serde(default)]
|
||||
pub renesas: Vec<DriveProfile>,
|
||||
}
|
||||
|
||||
/// Drive identity — matched against INQUIRY data.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
/// Drive vendor from INQUIRY[8:16] (e.g. "HL-DT-ST")
|
||||
pub struct Identity {
|
||||
#[serde(default)]
|
||||
pub vendor_id: String,
|
||||
|
||||
/// Drive product (devtype) from INQUIRY product field (e.g. "BD-RE")
|
||||
#[serde(default)]
|
||||
pub product_id: String,
|
||||
|
||||
/// Firmware revision from INQUIRY[32:36] (e.g. "1.03")
|
||||
#[serde(default)]
|
||||
pub product_revision: String,
|
||||
|
||||
/// Firmware type from INQUIRY[36:43] (e.g. "NM00000")
|
||||
#[serde(default)]
|
||||
pub vendor_specific: String,
|
||||
|
||||
/// Firmware build date from GET_CONFIG 010C (e.g. "211810241934")
|
||||
#[serde(default)]
|
||||
pub firmware_date: String,
|
||||
}
|
||||
|
||||
/// Chipset manufacturer determining unlock/read command structure.
|
||||
#[serde(default)]
|
||||
pub chipset: Chipset,
|
||||
|
||||
/// READ BUFFER mode byte for unlock CDB (e.g. 0x01 for MT1959-A, 0x02 for MT1959-B).
|
||||
#[serde(default = "default_unlock_mode")]
|
||||
pub unlock_mode: u8,
|
||||
|
||||
/// READ BUFFER buffer ID for unlock CDB (e.g. 0x44 for MT1959-A, 0x77 for MT1959-B).
|
||||
#[serde(default = "default_unlock_buf_id")]
|
||||
pub unlock_buf_id: u8,
|
||||
|
||||
|
||||
/// Drive identifier string from the profile database.
|
||||
#[serde(default)]
|
||||
pub drive_id: String,
|
||||
|
||||
/// Profile version string.
|
||||
#[serde(default)]
|
||||
pub drive_version: String,
|
||||
|
||||
/// Expected response signature bytes [0:4] from the enable command.
|
||||
/// Per-drive profile.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
pub identity: Identity,
|
||||
#[serde(default, deserialize_with = "deserialize_hex4")]
|
||||
pub signature: [u8; 4],
|
||||
|
||||
/// Expected verification bytes [12:16] from the enable response.
|
||||
#[serde(skip, default = "default_verify")]
|
||||
pub verify: [u8; 4],
|
||||
|
||||
/// 10-byte READ BUFFER CDB used to enable raw disc access.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub unlock_cdb: Vec<u8>,
|
||||
|
||||
/// Register read offsets (bytes 3-5 of READ BUFFER CDB).
|
||||
#[serde(default)]
|
||||
pub register_offsets: Vec<u32>,
|
||||
|
||||
/// Drive supports reading DVDs regardless of region code.
|
||||
#[serde(default)]
|
||||
pub dvd_all_regions: bool,
|
||||
|
||||
/// Drive supports raw Blu-ray sector reads.
|
||||
#[serde(default)]
|
||||
pub bd_raw_read: bool,
|
||||
|
||||
/// Drive supports raw Blu-ray metadata reads.
|
||||
#[serde(default)]
|
||||
pub bd_raw_metadata: bool,
|
||||
|
||||
/// Drive supports unrestricted read speed.
|
||||
#[serde(default)]
|
||||
pub unrestricted_speed: bool,
|
||||
#[serde(default, deserialize_with = "deserialize_base64")]
|
||||
pub firmware: Vec<u8>,
|
||||
}
|
||||
|
||||
fn default_verify() -> [u8; 4] {
|
||||
*b"MMkv"
|
||||
}
|
||||
|
||||
fn default_unlock_mode() -> u8 {
|
||||
0x01
|
||||
}
|
||||
|
||||
fn default_unlock_buf_id() -> u8 {
|
||||
0x44
|
||||
}
|
||||
|
||||
/// Drive chipset — determines CDB structure for unlock and raw read commands.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
||||
pub enum Chipset {
|
||||
/// MediaTek MT1959 — LG, ASUS, hp drives.
|
||||
/// CDB: READ_BUFFER with mode and buf_id from profile.
|
||||
#[serde(rename = "mediatek")]
|
||||
MediaTek,
|
||||
/// Renesas RS8xxx/RS9xxx — Pioneer, some HL-DT-ST drives.
|
||||
/// Not yet implemented.
|
||||
#[serde(rename = "renesas")]
|
||||
/// Chipset + variant — determined by which section the profile was found in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Platform {
|
||||
Mt1959A,
|
||||
Mt1959B,
|
||||
Renesas,
|
||||
}
|
||||
|
||||
impl Default for Chipset {
|
||||
fn default() -> Self {
|
||||
Chipset::MediaTek
|
||||
}
|
||||
}
|
||||
|
||||
impl Chipset {
|
||||
/// Human-readable name for this chipset.
|
||||
impl Platform {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Chipset::MediaTek => "MediaTek MT1959",
|
||||
Chipset::Renesas => "Renesas",
|
||||
Platform::Mt1959A => "MediaTek MT1959",
|
||||
Platform::Mt1959B => "MediaTek MT1959",
|
||||
Platform::Renesas => "Renesas",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a hex string like "999ec375" into [u8; 4].
|
||||
/// Result of profile lookup.
|
||||
pub struct ProfileMatch {
|
||||
pub profile: DriveProfile,
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_hex4(s: &str) -> Result<[u8; 4]> {
|
||||
if s.len() != 8 {
|
||||
return Err(Error::ProfileParse { detail: format!("expected 8 hex chars, got {}", s.len()) });
|
||||
return Err(Error::ProfileParse);
|
||||
}
|
||||
let mut out = [0u8; 4];
|
||||
for i in 0..4 {
|
||||
out[i] = u8::from_str_radix(&s[i*2..i*2+2], 16)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("bad hex: {e}") })?;
|
||||
.map_err(|_| Error::ProfileParse)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse a hex string into a byte vector.
|
||||
fn parse_hex(s: &str) -> Result<Vec<u8>> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(Error::ProfileParse { detail: "odd hex length".into() });
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
out.push(u8::from_str_radix(&s[i..i+2], 16)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("bad hex: {e}") })?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Custom serde deserializer for 4-byte hex signature strings.
|
||||
fn deserialize_hex4<'de, D>(deserializer: D) -> std::result::Result<[u8; 4], D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
where D: serde::Deserializer<'de> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if s.is_empty() { return Ok([0; 4]); }
|
||||
parse_hex4(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// Custom serde deserializer for hex-encoded byte vectors.
|
||||
fn deserialize_hex_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
fn deserialize_base64<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
||||
where D: serde::Deserializer<'de> {
|
||||
use base64::Engine;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
parse_hex(&s).map_err(serde::de::Error::custom)
|
||||
if s.is_empty() { return Ok(Vec::new()); }
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&s)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// Load a profile from a parsed JSON value.
|
||||
pub fn load_from_json(json: &serde_json::Value) -> Result<DriveProfile> {
|
||||
let vendor = json["vendor_id"].as_str().unwrap_or("").to_string();
|
||||
let product = json["product_id"].as_str().unwrap_or("").to_string();
|
||||
let revision = json["product_revision"].as_str().unwrap_or("").to_string();
|
||||
let firmware_type = json["vendor_specific"].as_str().unwrap_or("").to_string();
|
||||
let firmware_date = json["firmware_date"].as_str().unwrap_or("").to_string();
|
||||
let chipset_str = json["chipset"].as_str().unwrap_or("unknown");
|
||||
// ── Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
let chipset = match chipset_str {
|
||||
"mediatek" => Chipset::MediaTek,
|
||||
"renesas" => Chipset::Renesas,
|
||||
_ => Chipset::MediaTek,
|
||||
};
|
||||
|
||||
let unlock_mode = json["unlock_mode"].as_u64().map(|v| v as u8).unwrap_or(0x01);
|
||||
let unlock_buf_id = json["unlock_buf_id"].as_u64().map(|v| v as u8).unwrap_or(0x44);
|
||||
|
||||
let sig_str = json["signature"].as_str().unwrap_or("");
|
||||
|
||||
let signature = if sig_str.len() == 8 {
|
||||
parse_hex4(sig_str)?
|
||||
} else {
|
||||
[0; 4]
|
||||
};
|
||||
|
||||
let unlock_cdb = json["unlock_cdb"].as_str()
|
||||
.map(|s| parse_hex(s))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let register_offsets = json["register_cdbs"].as_array()
|
||||
.map(|arr| {
|
||||
arr.iter().filter_map(|v| {
|
||||
let s = v.as_str()?;
|
||||
// CDB format: 3c 01 44 XX XX XX 00 00 24 00
|
||||
// Register offset is bytes 3-5 (chars 6-12 in hex)
|
||||
if s.len() >= 12 {
|
||||
u32::from_str_radix(&s[6..12], 16).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(DriveProfile {
|
||||
vendor_id: vendor,
|
||||
product_id: product,
|
||||
product_revision: revision,
|
||||
vendor_specific: firmware_type,
|
||||
firmware_date,
|
||||
chipset,
|
||||
unlock_mode,
|
||||
unlock_buf_id,
|
||||
drive_id: json["drive_id"].as_str().unwrap_or("").to_string(),
|
||||
drive_version: json["drive_version"].as_str().unwrap_or("").to_string(),
|
||||
signature,
|
||||
verify: *b"MMkv",
|
||||
unlock_cdb,
|
||||
register_offsets,
|
||||
dvd_all_regions: json["capabilities"]["dvd_all_regions"].as_bool().unwrap_or(false),
|
||||
bd_raw_read: json["capabilities"]["bd_raw_read"].as_bool().unwrap_or(false),
|
||||
bd_raw_metadata: json["capabilities"]["bd_raw_metadata"].as_bool().unwrap_or(false),
|
||||
unrestricted_speed: json["capabilities"]["unrestricted_speed"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Bundled profiles — compiled into the binary.
|
||||
/// Override with load_all() to load from a file instead.
|
||||
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
||||
|
||||
/// Load profiles from the bundled database.
|
||||
pub fn load_bundled() -> Result<Vec<DriveProfile>> {
|
||||
pub fn load_bundled() -> Result<ProfilesFile> {
|
||||
load_from_str(BUNDLED_PROFILES)
|
||||
}
|
||||
|
||||
/// Load all profiles from a JSON array file.
|
||||
pub fn load_all(path: &std::path::Path) -> Result<Vec<DriveProfile>> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
load_from_str(&data)
|
||||
}
|
||||
|
||||
/// Parse profiles from a JSON string.
|
||||
fn load_from_str(data: &str) -> Result<Vec<DriveProfile>> {
|
||||
let json: serde_json::Value = serde_json::from_str(data)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("JSON: {e}") })?;
|
||||
|
||||
let arr = json.as_array()
|
||||
.ok_or_else(|| Error::ProfileParse { detail: "expected array".into() })?;
|
||||
|
||||
let mut profiles = Vec::with_capacity(arr.len());
|
||||
for entry in arr {
|
||||
match load_from_json(entry) {
|
||||
Ok(p) => profiles.push(p),
|
||||
Err(_) => continue, // skip malformed entries
|
||||
}
|
||||
}
|
||||
Ok(profiles)
|
||||
fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
serde_json::from_str(data)
|
||||
.map_err(|_| Error::ProfileParse)
|
||||
}
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
///
|
||||
/// Matches by vendor + product + revision + vendor_specific (firmware type).
|
||||
/// All fields trimmed before comparison.
|
||||
pub fn find_by_drive_id<'a>(
|
||||
profiles: &'a [DriveProfile],
|
||||
pub fn find_by_drive_id(
|
||||
profiles: &ProfilesFile,
|
||||
drive_id: &crate::identity::DriveId,
|
||||
) -> Option<&'a DriveProfile> {
|
||||
) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let r = drive_id.product_revision.trim();
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
let date = drive_id.firmware_date.trim();
|
||||
|
||||
// Match all four INQUIRY fields for precise identification
|
||||
profiles.iter().find(|p| {
|
||||
p.vendor_id.trim() == v
|
||||
&& p.product_revision.trim() == r
|
||||
&& p.vendor_specific.trim() == vs
|
||||
&& p.firmware_date.trim() == drive_id.firmware_date.trim()
|
||||
})
|
||||
// Fallback: match without date (for drives where 010C isn't available)
|
||||
.or_else(|| profiles.iter().find(|p| {
|
||||
p.vendor_id.trim() == v
|
||||
&& p.product_revision.trim() == r
|
||||
&& p.vendor_specific.trim() == vs
|
||||
}))
|
||||
for (platform, list) in [
|
||||
(Platform::Mt1959A, &profiles.mt1959_a),
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch { profile: p.clone(), platform });
|
||||
}
|
||||
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
}) {
|
||||
return Some(ProfileMatch { profile: p.clone(), platform });
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -314,9 +158,9 @@ mod tests {
|
||||
fn test_find_known_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
let p = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(p.vendor_id.trim(), "HL-DT-ST");
|
||||
assert_eq!(p.vendor_specific.trim(), "NM00000");
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
-248
@@ -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: libc::c_ulong = 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, &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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
}
|
||||
+5
-95
@@ -1,41 +1,14 @@
|
||||
//! Drive speed control — query and set read speeds.
|
||||
//!
|
||||
//! Uses MMC-6 SET CD SPEED (0xBB) command.
|
||||
//! Reference: MMC-6 §6.30
|
||||
//! Drive speed constants.
|
||||
|
||||
/// Disc read speed.
|
||||
/// Common optical drive speeds with KB/s values for SET_CD_SPEED.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum DriveSpeed {
|
||||
/// Blu-ray 1x = 4,500 KB/s
|
||||
BD1x,
|
||||
/// Blu-ray 2x = 9,000 KB/s
|
||||
BD2x,
|
||||
/// Blu-ray 4x = 18,000 KB/s
|
||||
BD4x,
|
||||
/// Blu-ray 6x = 27,000 KB/s
|
||||
BD6x,
|
||||
/// Blu-ray 8x = 36,000 KB/s
|
||||
BD8x,
|
||||
/// Blu-ray 10x = 45,000 KB/s
|
||||
BD10x,
|
||||
/// Blu-ray 12x = 54,000 KB/s
|
||||
BD12x,
|
||||
/// DVD 1x = 1,385 KB/s
|
||||
DVD1x,
|
||||
/// DVD 2x = 2,770 KB/s
|
||||
DVD2x,
|
||||
/// DVD 4x = 5,540 KB/s
|
||||
DVD4x,
|
||||
/// DVD 8x = 11,080 KB/s
|
||||
DVD8x,
|
||||
/// DVD 16x = 22,160 KB/s
|
||||
DVD16x,
|
||||
/// Maximum speed — drive decides
|
||||
BD1x, BD2x, BD4x, BD6x, BD8x, BD10x, BD12x,
|
||||
DVD1x, DVD2x, DVD4x, DVD8x, DVD16x,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl DriveSpeed {
|
||||
/// Convert to KB/s for MMC-6 SET CD SPEED command.
|
||||
pub fn to_kbps(self) -> u16 {
|
||||
match self {
|
||||
DriveSpeed::BD1x => 4_500,
|
||||
@@ -53,73 +26,10 @@ impl DriveSpeed {
|
||||
DriveSpeed::Max => 0xFFFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from KB/s value, rounding to nearest standard speed.
|
||||
pub fn from_kbps(kbps: u16) -> Self {
|
||||
match kbps {
|
||||
0..=2_000 => DriveSpeed::DVD1x,
|
||||
2_001..=4_000 => DriveSpeed::DVD2x,
|
||||
4_001..=6_000 => DriveSpeed::BD1x,
|
||||
6_001..=13_000 => DriveSpeed::BD2x,
|
||||
13_001..=22_000 => DriveSpeed::BD4x,
|
||||
22_001..=31_000 => DriveSpeed::BD6x,
|
||||
31_001..=40_000 => DriveSpeed::BD8x,
|
||||
40_001..=49_000 => DriveSpeed::BD10x,
|
||||
49_001..=0xFFFE => DriveSpeed::BD12x,
|
||||
0xFFFF => DriveSpeed::Max,
|
||||
_ => DriveSpeed::Max,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
DriveSpeed::BD1x => "BD 1x",
|
||||
DriveSpeed::BD2x => "BD 2x",
|
||||
DriveSpeed::BD4x => "BD 4x",
|
||||
DriveSpeed::BD6x => "BD 6x",
|
||||
DriveSpeed::BD8x => "BD 8x",
|
||||
DriveSpeed::BD10x => "BD 10x",
|
||||
DriveSpeed::BD12x => "BD 12x",
|
||||
DriveSpeed::DVD1x => "DVD 1x",
|
||||
DriveSpeed::DVD2x => "DVD 2x",
|
||||
DriveSpeed::DVD4x => "DVD 4x",
|
||||
DriveSpeed::DVD8x => "DVD 8x",
|
||||
DriveSpeed::DVD16x => "DVD 16x",
|
||||
DriveSpeed::Max => "Max",
|
||||
}
|
||||
}
|
||||
|
||||
/// All standard Blu-ray speeds.
|
||||
pub fn all_bd() -> &'static [DriveSpeed] {
|
||||
&[DriveSpeed::BD1x, DriveSpeed::BD2x, DriveSpeed::BD4x,
|
||||
DriveSpeed::BD6x, DriveSpeed::BD8x, DriveSpeed::BD10x, DriveSpeed::BD12x]
|
||||
}
|
||||
|
||||
/// All standard DVD speeds.
|
||||
pub fn all_dvd() -> &'static [DriveSpeed] {
|
||||
&[DriveSpeed::DVD1x, DriveSpeed::DVD2x, DriveSpeed::DVD4x,
|
||||
DriveSpeed::DVD8x, DriveSpeed::DVD16x]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DriveSpeed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} ({} KB/s)", self.label(), self.to_kbps())
|
||||
write!(f, "{:?} ({} KB/s)", self, self.to_kbps())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build SET CD SPEED CDB — MMC-6 §6.30
|
||||
pub fn set_cd_speed_cdb(read_speed: DriveSpeed) -> [u8; 12] {
|
||||
let kbps = read_speed.to_kbps();
|
||||
[
|
||||
0xBB, // SET CD SPEED opcode
|
||||
0x00, // reserved
|
||||
(kbps >> 8) as u8, // read speed MSB
|
||||
kbps as u8, // read speed LSB
|
||||
0xFF, // write speed MSB (0xFFFF = don't change)
|
||||
0xFF, // write speed LSB
|
||||
0x00, 0x00, 0x00, 0x00, // reserved
|
||||
0x00, 0x00, // reserved
|
||||
]
|
||||
}
|
||||
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
//! UDF filesystem reader — read files from Blu-ray discs.
|
||||
//!
|
||||
//! Blu-ray discs use UDF 2.50 with metadata partitions.
|
||||
//! The read sequence follows pointers through the disc structure:
|
||||
//!
|
||||
//! Sector 256 (AVDP)
|
||||
//! → VDS (Partition Descriptor + Logical Volume Descriptor)
|
||||
//! → Metadata Partition (virtual partition stored as a file)
|
||||
//! → File Set Descriptor (FSD)
|
||||
//! → Root Directory ICB
|
||||
//! → Directory data (File Identifier Descriptors)
|
||||
//! → BDMV/PLAYLIST/*.mpls, BDMV/CLIPINF/*.clpi
|
||||
//!
|
||||
//! Each step reads one or two sectors. No bulk reads needed.
|
||||
//!
|
||||
//! References:
|
||||
//! ECMA-167 (UDF base)
|
||||
//! UDF 2.50 (OSTA) — metadata partition extension
|
||||
//! BD-ROM Part 3 — Blu-ray filesystem profile
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::drive::DriveSession;
|
||||
|
||||
/// A UDF filesystem parsed from disc.
|
||||
#[derive(Debug)]
|
||||
pub struct UdfFs {
|
||||
/// Root directory with full tree
|
||||
pub root: DirEntry,
|
||||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
/// LBA within the metadata partition (add metadata_start for absolute)
|
||||
pub meta_lba: u32,
|
||||
/// File size in bytes (from ICB info_length)
|
||||
pub size: u64,
|
||||
/// Child entries (if directory)
|
||||
pub entries: Vec<DirEntry>,
|
||||
}
|
||||
|
||||
impl UdfFs {
|
||||
/// 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> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
})?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
/// Read a file by path, returning its raw bytes.
|
||||
/// Reads sector by sector from disc — no buffering.
|
||||
/// Get the absolute starting LBA of a file on disc.
|
||||
/// Used by the rip pipeline to locate m2ts content sectors.
|
||||
pub fn file_start_lba(&self, session: &mut DriveSession, path: &str) -> Result<u32> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
let entry = current.entries.iter().find(|e| {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
let (data_lba, _) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
Ok(self.partition_start + data_lba)
|
||||
}
|
||||
|
||||
pub fn read_file(&self, session: &mut DriveSession, path: &str) -> Result<Vec<u8>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
|
||||
// Navigate to parent directory
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
|
||||
// Find the file
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
let entry = current.entries.iter().find(|e| {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
|
||||
// Read the file's ICB to get its data extent
|
||||
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
|
||||
// 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.partition_start + data_lba;
|
||||
|
||||
for i in 0..sector_count {
|
||||
let offset = (i as usize) * 2048;
|
||||
read_sector(session, abs_start + i, &mut data[offset..offset + 2048])?;
|
||||
}
|
||||
|
||||
data.truncate(entry.size as usize);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
|
||||
/// and return its first allocation extent: (data_lba, data_length).
|
||||
/// The data_lba is partition-relative.
|
||||
fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
let extents = self.read_icb_extents(session, meta_lba)?;
|
||||
extents.first().copied().ok_or_else(|| Error::DiscRead { sector: 0 })
|
||||
}
|
||||
|
||||
/// Read ALL allocation extents for a file from its ICB.
|
||||
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
|
||||
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents).
|
||||
fn read_icb_extents(&self, session: &mut DriveSession, meta_lba: u32) -> Result<Vec<(u32, u32)>> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, self.meta_to_abs(meta_lba), &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
// Get allocation descriptor offset and total length based on ICB type
|
||||
let (ad_offset, l_ad) = match tag {
|
||||
// Extended File Entry (UDF 2.50, used by BD-ROM)
|
||||
266 => {
|
||||
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
|
||||
let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize;
|
||||
(216 + l_ea, l_ad)
|
||||
}
|
||||
// Standard File Entry
|
||||
261 => {
|
||||
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
|
||||
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
|
||||
(176 + l_ea, l_ad)
|
||||
}
|
||||
_ => return Err(Error::DiscRead { sector: 0 }),
|
||||
};
|
||||
|
||||
let mut extents = Vec::new();
|
||||
let num_descriptors = l_ad / 8; // Short Allocation Descriptor = 8 bytes
|
||||
|
||||
for i in 0..num_descriptors {
|
||||
let off = ad_offset + i * 8;
|
||||
if off + 8 > icb.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]);
|
||||
let extent_type = raw_len >> 30;
|
||||
let data_len = raw_len & 0x3FFFFFFF;
|
||||
let data_lba = u32::from_le_bytes([icb[off + 4], icb[off + 5], icb[off + 6], icb[off + 7]]);
|
||||
|
||||
match extent_type {
|
||||
0 => extents.push((data_lba, data_len)), // recorded and allocated
|
||||
1 => {} // allocated but not recorded (sparse) — skip
|
||||
3 => break, // next extent of allocation descriptors — TODO
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(extents)
|
||||
}
|
||||
|
||||
/// Get all absolute disc sector extents for a file.
|
||||
/// Returns Vec of (absolute_lba, sector_count) covering the entire file.
|
||||
pub fn file_extents(&self, session: &mut DriveSession, path: &str) -> Result<Vec<(u32, u32)>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
let entry = current.entries.iter().find(|e| {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
|
||||
let alloc_extents = self.read_icb_extents(session, entry.meta_lba)?;
|
||||
let mut disc_extents = Vec::new();
|
||||
for (lba, byte_len) in alloc_extents {
|
||||
let abs_lba = self.partition_start + lba;
|
||||
let sectors = ((byte_len as u64 + 2047) / 2048) as u32;
|
||||
disc_extents.push((abs_lba, sectors));
|
||||
}
|
||||
Ok(disc_extents)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the UDF filesystem from a Blu-ray disc.
|
||||
///
|
||||
/// Follows the UDF pointer chain:
|
||||
/// 1. AVDP (sector 256) → VDS location
|
||||
/// 2. VDS → Partition Descriptor (physical partition start)
|
||||
/// → Logical Volume Descriptor (FSD location + partition maps)
|
||||
/// 3. Metadata partition file → metadata content location
|
||||
/// 4. FSD → root directory ICB
|
||||
/// 5. Root directory → file tree
|
||||
pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
// Step 1: Anchor Volume Descriptor Pointer at sector 256
|
||||
// ECMA-167 §10.2 — always at sector 256
|
||||
let mut avdp = [0u8; 2048];
|
||||
read_sector(session, 256, &mut avdp)?;
|
||||
|
||||
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
||||
if tag_id != 2 {
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
|
||||
// (We use the VDS at sectors 32+, not the reserve copy at sector 32768+)
|
||||
|
||||
// Step 2: Read Volume Descriptor Sequence (sectors 32-37 typically)
|
||||
// Find Partition Descriptor (tag 5) and Logical Volume Descriptor (tag 6)
|
||||
let mut partition_start: u32 = 0;
|
||||
let mut num_partition_maps: u32 = 0;
|
||||
let mut lvd_sector: Option<u32> = None;
|
||||
let mut volume_id = String::new();
|
||||
let mut metadata_size_bytes: u32 = 0;
|
||||
|
||||
for i in 32..64 {
|
||||
let mut desc = [0u8; 2048];
|
||||
read_sector(session, i, &mut desc)?;
|
||||
|
||||
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
|
||||
match desc_tag {
|
||||
// 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]]);
|
||||
}
|
||||
// Logical Volume Descriptor — contains FSD location and partition maps
|
||||
6 => {
|
||||
num_partition_maps = u32::from_le_bytes([desc[268], desc[269], desc[270], desc[271]]);
|
||||
lvd_sector = Some(i);
|
||||
}
|
||||
// Terminating Descriptor — end of VDS
|
||||
8 => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
if partition_start == 0 {
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Step 3: Parse partition maps from LVD to find metadata partition
|
||||
// BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition")
|
||||
// The metadata file is stored at lba=0 of the physical partition
|
||||
let metadata_start = if num_partition_maps >= 2 {
|
||||
let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscRead { sector: 0 })?;
|
||||
|
||||
// Read LVD to check partition map type
|
||||
let mut lvd = [0u8; 2048];
|
||||
read_sector(session, lvd_sec, &mut lvd)?;
|
||||
|
||||
// Parse partition maps starting at offset 440
|
||||
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
|
||||
let _pm1_type = lvd[440]; // First map type
|
||||
let pm1_len = lvd[441] as usize;
|
||||
|
||||
if pm1_len > 0 && 440 + pm1_len < 2048 {
|
||||
let pm2_type = lvd[440 + pm1_len]; // Second map type
|
||||
|
||||
if pm2_type == 2 {
|
||||
// Type 2 = metadata partition
|
||||
// The metadata file ICB is at physical partition lba 0
|
||||
// Read it to find where the metadata content starts
|
||||
let meta_file_lba = partition_start; // lba 0 of partition
|
||||
let mut meta_icb = [0u8; 2048];
|
||||
read_sector(session, meta_file_lba, &mut meta_icb)?;
|
||||
|
||||
let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]);
|
||||
if meta_tag == 266 {
|
||||
// Extended File Entry — get allocation extent
|
||||
let l_ea = u32::from_le_bytes([meta_icb[208], meta_icb[209],
|
||||
meta_icb[210], meta_icb[211]]) as usize;
|
||||
let ad_off = 216 + l_ea;
|
||||
let ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
|
||||
meta_icb[ad_off + 2], meta_icb[ad_off + 3]]) & 0x3FFFFFFF;
|
||||
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
|
||||
partition_start + ad_pos
|
||||
} else {
|
||||
// Fallback: no metadata partition, use physical partition directly
|
||||
partition_start
|
||||
}
|
||||
} else {
|
||||
partition_start
|
||||
}
|
||||
} else {
|
||||
partition_start
|
||||
}
|
||||
} else {
|
||||
// Single partition map — no metadata partition (older UDF)
|
||||
partition_start
|
||||
};
|
||||
|
||||
// Step 4: Read File Set Descriptor from metadata partition
|
||||
// FSD is at metadata-relative lba 0 (first sector of metadata content)
|
||||
let mut fsd = [0u8; 2048];
|
||||
read_sector(session, metadata_start, &mut fsd)?;
|
||||
|
||||
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
|
||||
if fsd_tag != 256 {
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Root Directory ICB: long_ad at FSD offset 400
|
||||
// long_ad = extent_length(4) + extent_location: lba(4) + part_ref(2) + impl_use(6)
|
||||
let root_lba = u32::from_le_bytes([fsd[404], fsd[405], fsd[406], fsd[407]]);
|
||||
|
||||
// Step 5: Read root directory and build file tree
|
||||
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
|
||||
|
||||
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
|
||||
|
||||
Ok(UdfFs {
|
||||
root,
|
||||
volume_id,
|
||||
partition_start,
|
||||
metadata_start,
|
||||
metadata_sectors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a UDF directory and its children (up to max_depth levels).
|
||||
///
|
||||
/// Each directory is an ICB (Extended File Entry) pointing to directory data
|
||||
/// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir
|
||||
/// and points to its ICB.
|
||||
fn read_directory(
|
||||
session: &mut DriveSession,
|
||||
part_start: u32,
|
||||
meta_start: u32,
|
||||
meta_lba: u32,
|
||||
name: &str,
|
||||
depth: u32,
|
||||
) -> Result<DirEntry> {
|
||||
// Read ICB for this directory
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
// Get allocation extent: where the directory data lives
|
||||
let (ad_len, ad_pos) = match tag {
|
||||
266 => {
|
||||
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
|
||||
let ad_off = 216 + l_ea;
|
||||
let len = u32::from_le_bytes([icb[ad_off], icb[ad_off + 1],
|
||||
icb[ad_off + 2], icb[ad_off + 3]]) & 0x3FFFFFFF;
|
||||
let pos = u32::from_le_bytes([icb[ad_off + 4], icb[ad_off + 5],
|
||||
icb[ad_off + 6], icb[ad_off + 7]]);
|
||||
(len, pos)
|
||||
}
|
||||
261 => {
|
||||
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
|
||||
let ad_off = 176 + l_ea;
|
||||
let len = u32::from_le_bytes([icb[ad_off], icb[ad_off + 1],
|
||||
icb[ad_off + 2], icb[ad_off + 3]]) & 0x3FFFFFFF;
|
||||
let pos = u32::from_le_bytes([icb[ad_off + 4], icb[ad_off + 5],
|
||||
icb[ad_off + 6], icb[ad_off + 7]]);
|
||||
(len, pos)
|
||||
}
|
||||
_ => {
|
||||
return Ok(DirEntry {
|
||||
name: name.to_string(), is_dir: true, meta_lba, size: 0, entries: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Read directory data
|
||||
let dir_abs = meta_start + ad_pos;
|
||||
let sector_count = ((ad_len + 2047) / 2048).min(64);
|
||||
let mut dir_data = vec![0u8; sector_count as usize * 2048];
|
||||
for i in 0..sector_count {
|
||||
read_sector(session, dir_abs + i,
|
||||
&mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048])?;
|
||||
}
|
||||
|
||||
// Parse File Identifier Descriptors
|
||||
let mut entries = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos + 38 < dir_data.len().min(ad_len as usize) {
|
||||
let fid_tag = u16::from_le_bytes([dir_data[pos], dir_data[pos + 1]]);
|
||||
if fid_tag != 257 {
|
||||
break;
|
||||
}
|
||||
|
||||
let file_chars = dir_data[pos + 18];
|
||||
let l_fi = dir_data[pos + 19] as usize;
|
||||
|
||||
// FID ICB is a long_ad starting at offset 20:
|
||||
// [20:24] = extent_length
|
||||
// [24:28] = extent_location (LBA within metadata partition)
|
||||
// [28:30] = partition_reference_number
|
||||
// [30:36] = implementation_use
|
||||
let icb_lba = u32::from_le_bytes([dir_data[pos + 24], dir_data[pos + 25],
|
||||
dir_data[pos + 26], dir_data[pos + 27]]);
|
||||
let l_iu = u16::from_le_bytes([dir_data[pos + 36], dir_data[pos + 37]]) as usize;
|
||||
|
||||
let is_dir = (file_chars & 0x02) != 0;
|
||||
let is_parent = (file_chars & 0x08) != 0;
|
||||
|
||||
if !is_parent && l_fi > 0 {
|
||||
let name_start = pos + 38 + l_iu;
|
||||
let entry_name = parse_udf_name(&dir_data[name_start..name_start + l_fi]);
|
||||
|
||||
if !entry_name.is_empty() {
|
||||
// Read the ICB to get file size
|
||||
let file_size = read_file_size(session, meta_start, icb_lba).unwrap_or(0);
|
||||
|
||||
if is_dir && depth < 3 {
|
||||
// Recurse into subdirectory (max 3 levels: BDMV/PLAYLIST/*.mpls)
|
||||
let subdir = read_directory(session, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
|
||||
entries.push(subdir);
|
||||
} else {
|
||||
entries.push(DirEntry {
|
||||
name: entry_name,
|
||||
is_dir,
|
||||
meta_lba: icb_lba,
|
||||
size: file_size,
|
||||
entries: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance to next FID (4-byte aligned)
|
||||
let fid_len = ((38 + l_iu + l_fi + 3) & !3) as usize;
|
||||
pos += fid_len;
|
||||
}
|
||||
|
||||
Ok(DirEntry {
|
||||
name: name.to_string(),
|
||||
is_dir: true,
|
||||
meta_lba,
|
||||
size: ad_len as u64,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read file size (info_length) from an Extended File Entry ICB.
|
||||
fn read_file_size(session: &mut DriveSession, meta_start: u32, meta_lba: u32) -> Result<u64> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
match tag {
|
||||
// Both File Entry (261) and Extended File Entry (266) have
|
||||
// info_length as a u64 at offset 56
|
||||
261 | 266 => {
|
||||
Ok(u64::from_le_bytes([icb[56], icb[57], icb[58], icb[59],
|
||||
icb[60], icb[61], icb[62], icb[63]]))
|
||||
}
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a UDF filename from raw bytes.
|
||||
///
|
||||
/// UDF uses a compression ID as the first byte:
|
||||
/// 8 = 8-bit characters (ASCII)
|
||||
/// 16 = 16-bit big-endian Unicode (UTF-16BE)
|
||||
fn parse_udf_name(data: &[u8]) -> String {
|
||||
if data.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match data[0] {
|
||||
8 => {
|
||||
// 8-bit ASCII
|
||||
String::from_utf8_lossy(&data[1..]).trim().to_string()
|
||||
}
|
||||
16 => {
|
||||
// 16-bit big-endian Unicode
|
||||
let mut s = String::new();
|
||||
let chars = &data[1..];
|
||||
for i in (0..chars.len()).step_by(2) {
|
||||
if i + 1 < chars.len() {
|
||||
let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
|
||||
if let Some(ch) = char::from_u32(c as u32) {
|
||||
s.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
_ => String::from_utf8_lossy(&data[1..]).trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
session.read_disc(lba, 1, buf)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user