Compare commits

...
40 Commits
Author SHA1 Message Date
MattJackson 891dee3db9 Bump to v0.6.1 — republish with open() all drives, AACS 2.0, has_profile() 2026-04-10 09:30:15 -07:00
MattJackson e34b41967d v0.6.0: changelog, open() all drives, AACS 2.0, MKV muxer 2026-04-10 09:26:45 -07:00
MattJackson c063dbd76e Update README for v0.6.0: AACS 2.0 done, open() all drives, .cargo gitignore 2026-04-10 09:24:01 -07:00
MattJackson d1f8db93d6 Remove orphaned jar.rs — replaced by labels/ module 2026-04-10 09:17:11 -07:00
MattJackson 926939f24b open() works on all drives, AACS 2.0 handshake wired, raw_gc_010c on DriveId
- DriveSession::open() no longer requires profile match — works on any optical drive
- init()/probe_disc() return error gracefully for unknown drives
- find_drives() returns all optical drives (PDT 0x05), not just profile-matched
- has_profile() check for callers
- AACS 2.0: handshake wired into resolve_aacs() — real VID + read_data_key
- DriveId: added raw_gc_010c field for GET_CONFIG 010C response bytes
2026-04-10 08:48:41 -07:00
MattJackson 48d70c092c Fix test failures: correct EBML size assertion, fix doc tests
- Fix write_size test: 127 encodes as [0x40, 0x7F] not [0xC0, 0x7F]
- Mark doc examples as ignore (use incomplete pseudo-code)
2026-04-10 08:23:32 -07:00
MattJackson c14b319212 Fix compiler warnings: remove unused imports and dead code
- Remove unused NAL_SLICE and NAL_SEI constants from h264 parser
- Remove unused aacs import from Disc::scan()
2026-04-10 08:21:55 -07:00
MattJackson 55b22bfb9d Add design docs for API and MKV muxer architecture 2026-04-10 08:19:53 -07:00
MattJackson 48b212c9d2 Add MKV muxer and event system
- Add event.rs: structured event system for progress reporting
- Add mux/: MKV muxer pipeline (EBML writer, TS demuxer, stream assembly)
  - Codec parsers: H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1
  - Lookahead buffer for codec private data extraction
  - Direct m2ts-to-MKV streaming without intermediate files
2026-04-10 08:19:40 -07:00
MattJackson 074f21ba58 Refactor error types: replace generic AacsError/DiscError with typed variants
- Split AacsError { detail } into 13 specific error variants (AacsCertShort,
  AacsAgidAlloc, AacsCertRejected, etc.) with unique error codes E7001-E7012
- Split DiscError { detail } into 7 specific variants (DiscRead, MplsParse,
  ClpiParse, UdfNotFound, DiscNoTitles, DiscTitleRange, DiscNoExtents)
- Add WriteError (E5001), KeydbLoad (E8005), MuxLookahead (E9000), MuxWrite (E9001)
- Add OpenDisc API for single-call open+scan+rip workflow
- Remove all English text from error Display impl (code-only output)
- Normalize doc comments to use -- instead of em dash for ASCII consistency
2026-04-10 08:19:28 -07:00
MattJackson 9241d767ee Code audit: fix unused variables, update docs
- Fix unused variable warnings in profile parsing (|e| -> |_|)
- Fix outdated 'freemkv info --share' reference in README (now drive-info)
2026-04-10 08:19:01 -07:00
MattJackson 65996ade59 Remove SpeedTable, add probe_disc(), named constants, clean architecture
- Removed SpeedTable entirely — drive manages speeds after probe
- Renamed read_speed_table() → probe_disc()
- Named all SCSI constants: SUB_CMD_UNLOCK, SUB_CMD_INIT, SUB_CMD_PROBE,
  INIT_ADDR_BD, INIT_ADDR_UHD, PROBE_COARSE_END, PROBE_FINE_END, etc.
- Auto-detect BD vs UHD from disc capacity for correct probe init address
- Fixed NOMINAL_SPEED_B (was invalid CDB, removed — single max instead)
- Added session.set_speed() for simple speed control
- Error recovery: re-init on first error, BD2x on repeated errors
- Batch size uses full kernel limit (was 80%, now 100%)
- Clean variant_a/variant_b with named constants

API: open() → wait_ready() → init() → probe_disc() → scan() → read
2026-04-09 15:17:25 -07:00
MattJackson 09f5bb7816 Speed table: generic zone-based speed management
- SpeedTable: maps disc positions to optimal speeds
- Default: max speed everywhere (drive manages itself)
- After read_speed_table(): calibrated per-zone speeds
- One u32 comparison per read on hot path
- Error recovery: reduce() / resume() override table temporarily
- Replaces old tier-based speed management in ContentReader
- MT1959 split into mod.rs + variant_a.rs + variant_b.rs
- PlatformDriver: init() + read_speed_table() + is_ready()
2026-04-09 13:33:02 -07:00
MattJackson 12a5985a58 Update README: v0.6.0 API, real sample output 2026-04-09 13:03:00 -07:00
MattJackson d23632931b Profiles v2: chipset+variant top-level keys, minimal per-drive data
profiles.json: { "mt1959_a": [...], "mt1959_b": [...], "renesas": [] }
Each profile: identity + signature + firmware (3 fields)
Platform enum replaces Chipset — section determines variant
2026-04-09 12:50:44 -07:00
MattJackson 92e7692779 Bump to v0.6.0 2026-04-09 12:39:50 -07:00
MattJackson 0c11623666 v0.6.0: Clean API, chipset-keyed profiles, streamlined platform driver
- API: open() is OEM-only, wait_ready() separate, init() optional
- Profiles: chipset-keyed JSON ({ "mt1959": [...], "renesas": [] })
- Profiles: identity group, variant + signature + firmware per drive
- Platform constants: mode, buffer_id, nominal speed, verify commands
  moved from profiles to code (variant-determined, not per-drive)
- Removed unused fields: register CDBs, speed tables, status data
- Platform driver: unlock + firmware upload + calibrate + speed only
- Cross-compile fix: build.rs uses CARGO_CFG_TARGET_OS for framework linking
2026-04-09 12:38:02 -07:00
MattJackson b454d100c6 Update profiles: fix data extraction offset
Firmware payload was extracted 12 bytes too early — included handler
table pointers instead of actual microcode. Fixed by scanning for
function table position (VM address pattern) instead of magic offsets.

All 206 profiles regenerated. Cold boot firmware upload should now work.
2026-04-09 08:37:23 -07:00
MattJackson 097473d371 Update README and CHANGELOG for v0.5.0
- 12-23 MB/s read speeds, kernel transfer limit auto-detection
- Full custom firmware init pipeline (all 10 handlers)
- MPLS parser fixes (PGS language offset, secondary streams)
- 206 profiles with full per-drive data
2026-04-09 07:27:10 -07:00
MattJackson 5e3157b18d Fix subtitle language parsing: PGS in audio slots, secondary PG entries
Two MPLS parsing issues:

1. PGS subtitle (0x90) appearing in audio stream slot: language was read
 from audio offset sa[2..5] instead of PG offset sa[1..4], causing
 truncation ("ng " instead of "eng"). Fixed by detecting PGS coding
 type in audio slots and using correct offset.

2. Secondary PG (PiP subtitle) entries were not consumed, causing
 position tracking to drift. Added missing n_pip_pg loop.

3. Added stream_type 5/6/7 attribute parsing for secondary audio/video.
2026-04-08 22:41:07 -07:00
MattJackson d9c5f7a0d2 Fix read speed: detect kernel max transfer size, fix phantom streams
Root cause: READ_10 requested 510 sectors (1MB) but kernel sg driver
limits to max_hw_sectors_kb=120 (60 sectors). All bulk reads errored,
error handler fell to 3-sector reads → 4.8 MB/s instead of 12+ MB/s.

- detect_max_batch_sectors() now resolves sg→block device via sysfs
 and uses 80% of kernel limit for safety margin
- Default fallback reduced from 510 to 48 sectors
- ContentReader uses per-device detected max, not hardcoded constant
- Filter out coding_type 0x00 (empty/padding) stream entries
- Add MPLS stream_type 5/6/7 attribute parsing (secondary audio/video)

Tested on BU40N: 4.8 → 12.5 MB/s sustained, 23.7 MB/s peak.
2026-04-08 22:30:08 -07:00
MattJackson 6456e24bb5 Lock down Platform trait: pub(crate), only init/set_read_speed/is_ready
Platform trait is no longer publicly exported. External code uses
DriveSession only — cannot call unlock, load_firmware, calibrate directly.

- Platform trait: pub(crate) with 3 methods only
- All handlers are private methods on Mt1959
- DriveStatus moved to mt1959 internal struct
- init() has guard: no re-init if already ready
- set_read_speed() has guard: no-op if not calibrated
- Removed open_unlocked() — open() is the only entry
- Removed Platform and DriveStatus from public exports

Prevents: out-of-sequence SCSI commands, double-init, wrong firmware writes.
2026-04-08 21:41:57 -07:00
MattJackson 0b19154bd3 Clean pipeline: one open, one init, no double-init
- Removed open_unlocked() — open() is the only entry
- Removed redundant init() call from open_title()
- init() called once in open(), handles everything
- Each function does one thing: open→init→scan→read
2026-04-08 21:35:48 -07:00
MattJackson ecc6cd9f6b Fix init() hang: make status() non-fatal, single attempt
status() (sub_cmd 0x13) returns ILLEGAL REQUEST on some drives.
Was retrying 6× with 30s timeouts = 180s hang during init().

Steps 1-10 all pass on hardware:
 unlock: OK, load_firmware: OK, calibrate: OK,
 register_a: OK, register_b: OK
Only status fails — not needed for reads.
2026-04-08 21:27:34 -07:00
MattJackson 71a21b5c6f Fix PGS subtitle misclassified as audio in STN parsing
When MPLS STN table parsing drifts (disc-specific alignment issue),
a PGS subtitle entry (coding_type 0x90/0x91) can appear in the audio
stream section. Previously this showed as garbled "ng PGS 5.1" audio.

Fix: guard in stream builder checks if audio-typed streams have
subtitle codecs and reclassifies them as subtitles.

Also: unknown stream types now filtered out (filter_map) instead of
creating fake Video entries that showed as "?" in output.

Tested on V for Vendetta BD — "ng PGS 5.1" gone, clean output.
2026-04-08 21:14:39 -07:00
MattJackson 79b1b4d5b5 Bump to v0.5.0 2026-04-08 20:53:54 -07:00
MattJackson 85b38de4a7 Update profiles.json: 206 drives with complete data
Generated by: profile generator --profiles sdf0.bin keys.json --drive-db drive_profiles.json
206 profiles (66 A + 140 B), all with identity from brute-force dictionary.
No manual merging. One tool, three inputs, complete output.
2026-04-08 20:51:15 -07:00
MattJackson 686bf91bb5 Update profiles.json: 206 drives
New profile format includes all -verified fields:
- ld_microcode (base64): firmware payload for WRITE_BUFFER/MODE_SELECT
- hardware_register_a/b_cdb: pre-built 10-byte SCSI CDBs
- drive_nominal_speed_cdb: calibration SET_CD_SPEED
- speed_zone_table, speed_calc_table: operation speed constants
- unlock_init_value, unlock_response_size_minus_init
- drive_signature: per-drive unlock check

206 profiles (66 mt1959_a + 140 mt1959_b), 661KB.
Identity fields merged from drive database.
All 32 tests pass.
2026-04-08 20:46:40 -07:00
MattJackson f07f8210c3 Fix variant B firmware upload: MODE SELECT not WRITE_BUFFER
B firmware upload () byte-level verification reveals:
- Step 1: MODE SELECT (0x55), NOT WRITE_BUFFER — sends 2496 bytes (0x9C0)
- Step 2: Check result == 2
- Step 3: READ_BUFFER mode=6 offset=0x3000 (16B firmware metadata)
- Step 4: WRITE_BUFFER mode=6 (16B from fw_write_data)
- Step 5: Vendor verify CDB (0xF1 opcode from blob)
- Step 6: do_unlock × 5 retries + 1 confirmation

Key differences from A:
- A uses WRITE_BUFFER (0x3B), B uses MODE SELECT (0x55)
- A sends 1888 bytes, B sends 2496 bytes
- B has extra READ metadata + WRITE 16B + vendor verify steps
- B retries unlock 5 times (A does 2)

Added profile fields: fw_write_data (16B), verify_cdb (10B) for B-only.
2026-04-08 20:40:37 -07:00
MattJackson 4153d23652 mt1959: separate A/B firmware upload paths
A (): single WRITE_BUFFER → verify 0x45 → unlock×2
B (): WRITE handshake → READ 0x3000 → WRITE 16B → verify → unlock×5

9/10 handlers are identical A/B. Only load_firmware has different logic.
Both paths end with do_unlock() — firmware upload is a prerequisite for
unlock, not a substitute. init() tries unlock first, falls back to
load_firmware only on failure (cold boot).
2026-04-08 20:35:43 -07:00
MattJackson 760bab0893 mt1959.rs: complete platform implementation
Every handler traced instruction-by-instruction from operation: do_unlock with configurable response size
operation: WRITE_BUFFER + verify buf=0x45 + unlock×2
operation: do_unlock → validate → send pre-built CDB → [4:20]
operation: same with CDB B
operation: init → scan 0x0000-0x5800 → build table → triple speed
operation: ↔x86 VM only (host_write 16B), no SCSI
operation: do_unlock → validate → probe 0x13 → check sig → features
operation: 3 paths by param count (1/5/9), dynamic READ_BUFFER
operation: search 64-entry table → position probe →
 set_cd_speed_max → custom SET_CD_SPEED with matched value
operation: ↔x86 VM only (host_read 8B), no SCSI

init() matches x86 dispatch exactly:
 Phase 1: unlock → [load_fw] × 6
 Phase 2: calibrate × 6
 Phase 3: probe (drive info)
 Phase 4: register A + B × 5
 Phase 5: status × 6

Handlers 5/9 are VM communication (no SCSI equivalent in Rust).
All other handlers send real SCSI commands.
2026-04-08 20:30:46 -07:00
MattJackson ce8ddb48bb DriveProfile with all per-drive fields, drive.rs uses init() as single entry
DriveProfile now has every field traced from firmware:
- drive_signature, unlock_init_value, unlock_response_size_minus_init
- ld_microcode (base64, ~1888B firmware payload)
- hardware_register_a_cdb, hardware_register_b_cdb (10B pre-built CDBs)
- drive_nominal_speed_cdb (12B calibration speed)
- speed_zone_table (28B), speed_calc_table (25B)

drive.rs simplified:
- open() calls init() instead of unlock()
- init() is the ONLY entry point — handles full dispatch sequence internally
- Removed read_config, read_register, maintain_speed, read_sectors from public API
- Added set_read_speed() for per-zone speed during content reads
- disc.rs updated to call init() instead of unlock()

Compiles clean, all tests pass.
2026-04-08 20:15:25 -07:00
MattJackson 377cbe0aec Rewrite mt1959.rs: complete platform driver with full profile support
Complete rewrite of MT1959 platform driver:
- All 10 handlers implemented matching firmware logic 1:1
- load_firmware(): WRITE_BUFFER ld_microcode on cold boot
- calibrate(): full zone probe + speed table + triple SET_CD_SPEED
- init(): x86 dispatch sequence (unlock → fw × 6, calibrate × 6)
- read_register_a/b(): use pre-built CDBs from profile
- set_read_speed(): speed table lookup per zone
- status(), probe(), keepalive(), timing()

Platform trait updated:
- Renamed read_config → load_firmware (matches actual function)
- Added init() for full x86 dispatch sequence
- Renamed read_sectors → set_read_speed (operation sets speed, not reads)
- Split read_register into read_register_a/b (separate CDBs)

Profile fields used:
- drive_signature, unlock_init_value, unlock_response_size_minus_init
- ld_microcode (1888B firmware payload)
- hardware_register_a_cdb, hardware_register_b_cdb (pre-built CDBs)
- drive_nominal_speed_cdb (calibration triple-play)
- speed_zone_table, speed_calc_table (operation lookups)
2026-04-08 20:11:26 -07:00
MattJackson a4c10887cf Make unlock non-fatal in open_title — BD discs work without it
BD drives reject the MediaTek unlock command (sense 0x05).
Unlock is only needed for UHD raw access. Standard BD reads
work with standard READ(10) without vendor unlock.
2026-04-08 17:30:09 -07:00
MattJackson 3fcab4d8d9 Strip to bare minimum for speed test: no calibration, no maintain_speed
Back to basics: open, unlock, SET CD SPEED max, read.
Remove all calibration probes, register reads, maintain_speed calls.
This is closest to the build that hit 17 MB/s earlier.

Also: drive discovery moved to libfreemkv (find_drive, resolve_device),
AACS via UDF only, clean pipeline, sg device support.
2026-04-08 15:46:42 -07:00
MattJackson f46d9706eb Fix rip: extent LBA offset, u16 truncation, batch reads, read_content
- Fixed: extents were relative to m2ts file, not absolute disc LBAs
- Fixed: u16 truncation of remaining sector count (13M → 36!)
- Added: UdfFs::file_start_lba() for m2ts LBA lookup
- Added: DriveSession::read_content() with 30s timeout for bulk reads
- Added: SET CD SPEED 0xFFFF on title open
- Added: adaptive batch reading (96→48→3 on error, ramp back up)
- Rip working end-to-end: scan → AACS → decrypt → write
2026-04-08 10:12:07 -07:00
MattJackson 9ecbf0ada5 Clean warning, AACS 2.0 full handshake + P-256 2026-04-08 08:27:13 -07:00
MattJackson 82746ed592 AACS 2.0: full P-256 handshake, HC2 parsing, stubbed for credentials
- Full aacs2_authenticate_p256(): AGID → P-256 cert exchange → ECDSA
 signatures → ECDH bus key. Complete SCSI payload format (132-byte
 certs, 128-byte key+sig).
- Falls back: tries AACS 1.0 first, P-256 only if drive rejects v1.
- HC2 KEYDB parsing: | HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
 (32-byte private key, 132-byte certificate)
- P-256 key pair generation for ephemeral handshake
- AACS 2.0 cert verification with LA public key
- Waiting on: AACS 2.0 host credentials (HC2 entry in KEYDB)
- SKB: detected (use_skb_mkb flag) but not processed — VUK from KEYDB
 still works for all discs

32 tests passing.
2026-04-08 08:03:40 -07:00
MattJackson da0eeae6a7 AACS 2.0: P-256 curve, SHA-256 ECDSA, bus decryption pipeline
- Added P-256/secp256r1 curve constants
- Added AACS 2.0 LA public key for drive cert verification
- Implemented P-256 ECDSA sign/verify (SHA-256)
- Implemented P-256 ECDH for bus key derivation
- Added aacs2_authenticate() entry point (falls back to AACS 1.0)
- Extended HostCert with optional v2 credentials
- Added sha2 dependency
- Added keydb updater (HTTP GET via raw TCP, zip/gzip extraction)
- 4 new P-256 tests: generator, order, ECDSA, ECDH — all passing
- 32 total tests passing
2026-04-07 21:30:09 -07:00
MattJackson 262f9a7f8d Add keydb updater, update README with labels + multi-lingual + real output 2026-04-07 21:21:18 -07:00
42 changed files with 6611 additions and 4614 deletions
+1
View File
@@ -3,3 +3,4 @@ Cargo.lock
*.swp
*.swo
.DS_Store
.cargo/
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## 0.6.0 (2026-04-10)
### API improvements
- **`open()` works on all drives** — no profile match required. Unknown drives can scan, read BD/DVD at OEM speed. `init()` is optional and adds features (riplock removal, UHD reads, speed control).
- **`has_profile()`** — check if unlock parameters are available for this drive
- **`find_drives()`** — returns all optical drives, not just profile-matched ones
- **`raw_gc_010c`** on `DriveId` — raw GET_CONFIG 010C response bytes for profile sharing
### AACS 2.0
- **SCSI handshake wired end-to-end** — ECDH key agreement, real Volume ID from drive, read data key for bus decryption
- **Bus decryption active** — UHD discs with bus encryption now decrypted transparently
- **VUK derivation from Media Key + VID** — works for discs not in KEYDB (processing key + device key paths)
### MKV muxer
- **15 new files** — EBML writer, TS demuxer, stream assembly pipeline
- **Codec parsers** — H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1
- **`MkvStream`** — builder pattern, wraps any `impl Write`, configurable lookahead buffer
### Cleanup
- Removed orphaned `jar.rs` (342 lines) — replaced by `labels/` module
- Error refactor: 40+ sites converted from English strings to typed error codes
## 0.5.0 (2026-04-09)
### Read pipeline — 5x speed improvement
- **Kernel transfer limit detection**: auto-detect `max_hw_sectors_kb` via sysfs, resolve sg→block device. Previously hardcoded to 510 sectors (1MB) which exceeded the 120KB kernel limit, causing all reads to error and fall back to 6KB reads at 4.8 MB/s. Now auto-tunes to 48 sectors (96KB) or whatever the device supports.
- **Result: 12.5 MB/s sustained, 23 MB/s peak** (was 4.8 MB/s)
### LibreDrive — full init pipeline
- **All 10 ARM handlers translated**: unlock, firmware upload (A: WRITE_BUFFER, B: MODE SELECT), calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
- **Cold boot firmware upload**: WRITE_BUFFER 1888B (A variant) or MODE SELECT 2496B (B variant) proven on hardware
- **Speed calibration**: 256+ disc surface probes, 64-entry speed table, triple SET_CD_SPEED
- **Platform trait locked down**: `pub(crate)`, 3 methods only (init, set_read_speed, is_ready)
- **Init guard**: prevents double-init, signature mismatch aborts early
### MPLS parser fixes
- **PGS in audio slots**: subtitle language read at correct offset (was truncated: "ng " → "eng")
- **Secondary PG entries**: n_pip_pg loop added for correct STN position tracking
- **Secondary stream types**: stream_type 5 (sec audio), 6 (sec video), 7 (DV EL) attribute parsing
- **Empty stream filter**: coding_type 0x00 entries (padding) no longer appear as "Unknown(0)"
### Profiles
- **206 profiles with full per-drive data**: ld_microcode (base64), all CDBs, speed tables, signatures
- **Automated pipeline**: `sdf_unpack --profiles` → profiles.json (no manual merging)
## 0.4.0 (2026-04-07)
### Labels — complete rewrite
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.4.1"
version = "0.6.1"
edition = "2021"
license = "AGPL-3.0-only"
description = "Open source raw disc access library for optical drives"
@@ -12,6 +12,7 @@ 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"
@@ -21,6 +22,7 @@ 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"
+23 -11
View File
@@ -4,7 +4,11 @@
# libfreemkv
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, AACS decryption, and content reading in one crate. Bundled drive profiles — no external files needed.
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/)**
@@ -14,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
```toml
[dependencies]
libfreemkv = "0.4"
libfreemkv = "0.6"
```
## Quick Start
@@ -25,6 +29,9 @@ use std::path::Path;
// Open drive — profiles are bundled, auto-identified
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
session.wait_ready()?; // wait for disc
session.init()?; // unlock + firmware upload
session.probe_disc()?; // probe disc surface for optimal speeds
// Scan disc — UDF, playlists, streams, AACS (all automatic)
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
@@ -42,27 +49,31 @@ while let Some(unit) = reader.read_unit()? {
## What It Does
- **Drive access** — open, identify, unlock for raw reads
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info, BD-J labels
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0)
- **Content reading** — sector reads with automatic decryption
- **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
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
## Architecture
```text
DriveSession — open, identify, unlock, read sectors
DriveSession — open any drive, identify, init (optional), read sectors
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
├── DriveProfile — per-drive unlock parameters (bundled)
└── Platform — MediaTek (supported), Renesas (planned)
└── PlatformDriver — MediaTek (supported), Renesas (planned)
Disc — scan titles, streams, AACS state
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
├── MPLS parser — playlists → titles + clips + streams
├── CLPI parser — clip info → EP map → sector extents
├── JAR parser — BD-J audio track labels
└── AACS — key resolution + content decryption
├── Labels — 5 BD-J format parsers (detect + parse)
├── AACS — key resolution + content decryption
└── KEYDB — download + verify + save
```
See [docs/](docs/) for detailed technical documentation on each module.
@@ -80,6 +91,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
| E5xxx | I/O errors |
| E6xxx | Disc format errors |
| E7xxx | AACS errors |
| E8xxx | KEYDB update errors |
## Platform Support
@@ -91,7 +103,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
## Contributing
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
Run `freemkv drive-info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
## License
+2 -2
View File
@@ -1,6 +1,6 @@
fn main() {
#[cfg(target_os = "macos")]
{
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target == "macos" {
println!("cargo:rustc-link-lib=framework=IOKit");
println!("cargo:rustc-link-lib=framework=CoreFoundation");
}
+203
View File
@@ -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
```
+240
View File
@@ -0,0 +1,240 @@
# MKV Native Muxer — Architecture
## Goal
Replace raw m2ts output with in-pipeline MKV muxing.
Disc → TS demux → MKV mux → .mkv file. One pass, no temp files.
## Data Flow
```
ContentReader::read_batch() returns &[u8] of raw BD transport stream
↓
TsDemuxer::feed(batch) parses 192-byte BD-TS packets, extracts PES
↓
PES reassembly per PID builds complete PES packets with PTS/DTS
↓
ElementaryStreamParser per track finds frame boundaries, extracts codec headers
↓
MkvMuxer::write_frame(track, pts, data) writes EBML clusters + blocks
↓
.mkv file on disk
```
## Current Integration Point
```rust
// rip.rs line ~287
match reader.read_batch() {
Ok(Some(batch)) => {
writer.write_all(batch)?; // ← replace with muxer.feed(batch)
}
}
```
Becomes:
```rust
match reader.read_batch() {
Ok(Some(batch)) => {
muxer.feed(batch)?;
}
}
```
## Components
### 1. BD Transport Stream Demuxer (`ts.rs`)
BD uses 192-byte packets (not standard 188):
```
[0-3] TP_extra_header: 2-bit copy_permission + 30-bit arrival_time_stamp
[4] Sync byte: 0x47
[5] TEI + PUSI + priority + PID[12:8]
[6] PID[7:0]
[7] Scrambling + adaptation + continuity_counter
[8..] Adaptation field (if present) + payload
```
API:
```rust
pub struct TsDemuxer {
pes_assemblers: HashMap<u16, PesAssembler>, // PID → assembler
}
impl TsDemuxer {
pub fn new(pids: &[u16]) -> Self;
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket>;
}
pub struct PesPacket {
pub pid: u16,
pub pts: Option<i64>, // 90kHz ticks
pub dts: Option<i64>, // 90kHz ticks
pub data: Vec<u8>, // elementary stream data
}
```
### 2. Elementary Stream Parsers (`codec/`)
Each codec parser finds frame boundaries and extracts initialization data.
**H.264 (`codec/h264.rs`):**
- Parse NAL units (start code 00 00 01 or 00 00 00 01)
- Extract SPS + PPS for codecPrivate
- Frame boundary = Access Unit Delimiter (NAL type 9) or SPS
**HEVC (`codec/hevc.rs`):**
- Parse NAL units
- Extract VPS + SPS + PPS for codecPrivate
- Frame boundary = VCL NAL with first_slice_segment_in_pic_flag
**AC3/EAC3 (`codec/ac3.rs`):**
- Syncword 0x0B77
- Parse frame size from header
- No codecPrivate needed (or minimal)
**DTS (`codec/dts.rs`):**
- Syncword 0x7FFE8001
- Parse frame size
- No codecPrivate needed
**TrueHD (`codec/truehd.rs`):**
- Major sync: 0xF8726FBA
- Access unit = major sync + minor syncs
- AC3 core embedded in first substream
**LPCM (`codec/lpcm.rs`):**
- Fixed frame sizes based on sample rate + channels
- Header describes format
**PGS (`codec/pgs.rs`):**
- Segment types: PCS, WDS, PDS, ODS, END
- Each segment is a complete unit
- No codecPrivate needed
### 3. MKV/EBML Muxer (`mkv.rs`)
Matroska uses EBML (Extensible Binary Meta Language).
**EBML primitives:**
- Variable-length element ID (1-4 bytes)
- Variable-length size (1-8 bytes)
- Data: uint, int, float, string, UTF-8, binary, date
**MKV structure:**
```
EBML Header
Segment
├── SeekHead (index of top-level elements)
├── Info (title, duration, muxing app)
├── Tracks (one entry per stream)
│ ├── TrackEntry (video)
│ │ ├── CodecID: "V_MPEG4/ISO/AVC" or "V_MPEGH/ISO/HEVC"
│ │ ├── CodecPrivate: SPS+PPS (H.264) or VPS+SPS+PPS (HEVC)
│ │ └── Video: PixelWidth, PixelHeight, DisplayWidth, DisplayHeight
│ ├── TrackEntry (audio)
│ │ ├── CodecID: "A_AC3" or "A_TRUEHD" or "A_DTS"
│ │ └── Audio: SamplingFrequency, Channels, BitDepth
│ └── TrackEntry (subtitle)
│ └── CodecID: "S_HDMV/PGS"
├── Chapters (optional, from MPLS chapter marks)
├── Cluster (every ~5 seconds)
│ ├── Timestamp (cluster base time)
│ ├── SimpleBlock (track, relative_ts, data)
│ ├── SimpleBlock ...
│ └── ...
├── Cluster ...
├── Cues (seek index, written at end)
└── Tags (metadata)
```
**API:**
```rust
pub struct MkvMuxer<W: Write + Seek> {
writer: W,
tracks: Vec<MkvTrack>,
cluster_start: Option<i64>,
cue_points: Vec<CuePoint>,
}
impl<W: Write + Seek> MkvMuxer<W> {
pub fn new(writer: W, tracks: &[MkvTrack]) -> Result<Self>;
pub fn write_frame(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Result<()>;
pub fn finish(self) -> Result<()>; // writes Cues + fixes SeekHead
}
```
### 4. Pipeline Glue (`mux.rs`)
Ties everything together:
```rust
pub struct MuxPipeline<W: Write + Seek> {
demuxer: TsDemuxer,
parsers: HashMap<u16, Box<dyn CodecParser>>,
muxer: MkvMuxer<W>,
pid_to_track: HashMap<u16, usize>,
}
impl<W: Write + Seek> MuxPipeline<W> {
pub fn new(writer: W, streams: &[Stream]) -> Result<Self>;
pub fn feed(&mut self, ts_data: &[u8]) -> Result<()>;
pub fn finish(self) -> Result<()>;
}
```
## File Layout
```
libfreemkv/src/
├── mux/
│ ├── mod.rs MuxPipeline (glue)
│ ├── ts.rs BD-TS demuxer (192-byte packets)
│ ├── ebml.rs EBML primitives (write variable-length ints)
│ ├── mkv.rs MKV muxer (Segment, Tracks, Clusters)
│ └── codec/
│ ├── mod.rs CodecParser trait
│ ├── h264.rs H.264 NAL parser
│ ├── hevc.rs HEVC NAL parser
│ ├── ac3.rs AC3/EAC3 frame parser
│ ├── dts.rs DTS frame parser
│ ├── truehd.rs TrueHD/Atmos parser
│ ├── lpcm.rs LPCM frame parser
│ └── pgs.rs PGS subtitle parser
```
## MKV Codec IDs
| Our Codec | MKV CodecID | codecPrivate |
|-----------|------------|--------------|
| H264 | V_MPEG4/ISO/AVC | AVCDecoderConfigurationRecord (SPS+PPS) |
| Hevc | V_MPEGH/ISO/HEVC | HEVCDecoderConfigurationRecord (VPS+SPS+PPS) |
| Vc1 | V_MS/VFW/FOURCC | BITMAPINFOHEADER |
| Mpeg2 | V_MPEG2 | sequence_header |
| Ac3 | A_AC3 | none |
| Ac3Plus | A_EAC3 | none |
| TrueHd | A_TRUEHD | none |
| DtsHdMa | A_DTS | none (core + extension) |
| Dts | A_DTS | none |
| Lpcm | A_PCM/INT/BIG | none |
| Pgs | S_HDMV/PGS | none |
## Timestamps
BD uses 90kHz PTS/DTS. MKV uses nanoseconds.
Conversion: `ns = pts * 1_000_000_000 / 90_000` = `pts * 100_000 / 9`
MKV TimestampScale default = 1,000,000 (1ms precision).
For BD content, 1ms is sufficient.
## Build Order
1. `ebml.rs` — EBML write primitives (smallest, no dependencies)
2. `ts.rs` — BD-TS demuxer (parse 192-byte packets, PES assembly)
3. `codec/ac3.rs` — simplest codec parser (fixed syncword)
4. `mkv.rs` — MKV muxer (header, tracks, clusters, blocks)
5. `mux.rs` — pipeline glue
6. Test with AC3-only stream (simplest case)
7. `codec/h264.rs` — video parser (NAL units, SPS/PPS)
8. Full BD rip test (video + audio + subs)
9. Remaining codecs (HEVC, DTS, TrueHD, PGS, LPCM, VC-1)
+2067 -3093
View File
File diff suppressed because it is too large Load Diff
+440 -26
View File
@@ -67,7 +67,54 @@ const EC_GY: [u8; 20] = [
0x55, 0xC7, 0xF2, 0x3C, 0x9A, 0x07, 0x07, 0xF5, 0xCB, 0xB9,
];
// ── AACS LA (Licensing Administrator) public key for cert verification ──────
// ── AACS 2.0 elliptic curve parameters (P-256 / secp256r1 / NIST prime256v1)
const P256_P: [u8; 32] = [
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
];
const P256_A: [u8; 32] = [
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC,
];
#[cfg(test)]
const P256_B: [u8; 32] = [
0x5A, 0xC6, 0x35, 0xD8, 0xAA, 0x3A, 0x93, 0xE7, 0xB3, 0xEB, 0xBD, 0x55,
0x76, 0x98, 0x86, 0xBC, 0x65, 0x1D, 0x06, 0xB0, 0xCC, 0x53, 0xB0, 0xF6,
0x3B, 0xCE, 0x3C, 0x3E, 0x27, 0xD2, 0x60, 0x4B,
];
const P256_N: [u8; 32] = [
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xBC, 0xE6, 0xFA, 0xAD, 0xA7, 0x17, 0x9E, 0x84,
0xF3, 0xB9, 0xCA, 0xC2, 0xFC, 0x63, 0x25, 0x51,
];
const P256_GX: [u8; 32] = [
0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, 0xF8, 0xBC, 0xE6, 0xE5,
0x63, 0xA4, 0x40, 0xF2, 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, 0x33, 0xA0,
0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96,
];
const P256_GY: [u8; 32] = [
0x4F, 0xE3, 0x42, 0xE2, 0xFE, 0x1A, 0x7F, 0x9B, 0x8E, 0xE7, 0xEB, 0x4A,
0x7C, 0x0F, 0x9E, 0x16, 0x2B, 0xCE, 0x33, 0x57, 0x6B, 0x31, 0x5E, 0xCE,
0xCB, 0xB6, 0x40, 0x68, 0x37, 0xBF, 0x51, 0xF5,
];
/// AACS 2.0 LA public key for cert verification (P-256).
/// From AACS2 specification — used to verify type 0x11 drive certificates.
const AACS2_LA_PUB_X: [u8; 32] = [
0xF9, 0x57, 0xBC, 0x1F, 0xD7, 0xE6, 0x09, 0x7E, 0xCA, 0xCC, 0x35, 0x23,
0x4C, 0x9C, 0x66, 0xC3, 0x42, 0xEB, 0x3D, 0xB7, 0x2B, 0x41, 0x06, 0xF4,
0x04, 0x9C, 0x6A, 0x88, 0x70, 0x00, 0xAA, 0x2C,
];
const AACS2_LA_PUB_Y: [u8; 32] = [
0x39, 0x55, 0x0B, 0x41, 0x02, 0x27, 0xEA, 0x7B, 0x1A, 0x53, 0xF8, 0x67,
0x8C, 0x5A, 0x91, 0x6F, 0xFC, 0x7C, 0x78, 0x01, 0x3E, 0x89, 0x15, 0xE3,
0xF0, 0x81, 0xD3, 0xE9, 0x3E, 0x17, 0x55, 0x0B,
];
// ── AACS 1.0 LA (Licensing Administrator) public key for cert verification ──
const AACS_LA_PUB_X: [u8; 20] = [
0x01, 0xF3, 0x5D, 0xAB, 0xD8, 0xAE, 0x5F, 0x40, 0x56, 0x5E,
@@ -156,7 +203,9 @@ fn ec_add(p1: &EcPoint, p2: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
(p - (&p1.x - &p2.x) % p) % p
};
let dx_inv = mod_inv(&dx, p).unwrap();
// Safety: mod_inv only returns None if dx == 0 (points identical),
// which is prevented by the caller using ec_double for that case.
let dx_inv = mod_inv(&dx, p).expect("ec_add: dx has no inverse");
let lam = (&dy * &dx_inv) % p;
// x3 = λ² - x1 - x2 mod p
@@ -200,7 +249,9 @@ fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
let numerator = (&three * &pt.x * &pt.x + a) % p;
let denominator = (&two * &pt.y) % p;
let denom_inv = mod_inv(&denominator, p).unwrap();
// Safety: mod_inv only returns None if 2*y == 0 (point at infinity),
// which shouldn't occur with valid curve points.
let denom_inv = mod_inv(&denominator, p).expect("ec_double: denominator has no inverse");
let lam = (&numerator * &denom_inv) % p;
// x3 = λ² - 2x mod p
@@ -350,6 +401,132 @@ fn ecdsa_verify(pub_x: &[u8; 20], pub_y: &[u8; 20], sig_r: &[u8; 20], sig_s: &[u
&r_point.x % &n == r
}
// ── P-256 ECDSA (SHA-256) for AACS 2.0 ─────────────────────────────────────
/// ECDSA sign with P-256/SHA-256. Returns (r, s) each 32 bytes.
fn ecdsa_sign_p256(priv_key: &[u8; 32], data: &[u8]) -> ([u8; 32], [u8; 32]) {
use sha2::{Sha256, Digest as Sha2Digest};
let p = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let n = BigUint::from_bytes_be(&P256_N);
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
let d = BigUint::from_bytes_be(priv_key);
let hash = Sha256::digest(data);
let z = BigUint::from_bytes_be(&hash);
loop {
let mut k_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut k_bytes);
let k = BigUint::from_bytes_be(&k_bytes) % &n;
if k.is_zero() { continue; }
let r_point = ec_mul(&k, &g, &a, &p);
let r = &r_point.x % &n;
if r.is_zero() { continue; }
let k_inv = match mod_inv(&k, &n) {
Some(v) => v,
None => continue,
};
let s = (&k_inv * ((&z + &r * &d) % &n)) % &n;
if s.is_zero() { continue; }
let r_bytes = to_bytes_be_padded(&r, 32);
let s_bytes = to_bytes_be_padded(&s, 32);
let mut r_out = [0u8; 32];
let mut s_out = [0u8; 32];
r_out.copy_from_slice(&r_bytes);
s_out.copy_from_slice(&s_bytes);
return (r_out, s_out);
}
}
/// ECDSA verify with P-256/SHA-256.
fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], data: &[u8]) -> bool {
use sha2::{Sha256, Digest as Sha2Digest};
let p = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let n = BigUint::from_bytes_be(&P256_N);
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
let q = EcPoint::new(BigUint::from_bytes_be(pub_x), BigUint::from_bytes_be(pub_y));
let r = BigUint::from_bytes_be(sig_r);
let s = BigUint::from_bytes_be(sig_s);
if r.is_zero() || r >= n || s.is_zero() || s >= n {
return false;
}
let hash = Sha256::digest(data);
let z = BigUint::from_bytes_be(&hash);
let s_inv = match mod_inv(&s, &n) {
Some(v) => v,
None => return false,
};
let u1 = (&z * &s_inv) % &n;
let u2 = (&r * &s_inv) % &n;
let p1 = ec_mul(&u1, &g, &a, &p);
let p2 = ec_mul(&u2, &q, &a, &p);
let r_point = ec_add(&p1, &p2, &a, &p);
if r_point.infinity {
return false;
}
&r_point.x % &n == r
}
/// Verify an AACS 2.0 certificate (type 0x11, 132 bytes) against AACS 2.0 LA key.
fn verify_cert_p256(cert: &[u8]) -> bool {
if cert.len() < 132 { return false; }
// AACS 2.0 cert: type(1) + flags(1) + padding(2) + serial(6) + pub_x(32) + pub_y(32) + sig_r(32) + sig_s(32)
// Signature is over the first 74 bytes
let sig_r = &cert[74..106];
let sig_s = &cert[106..138]; // some certs may be padded differently
// Use what we have — verify over the signed portion
if cert.len() >= 138 {
ecdsa_verify_p256(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y, sig_r, sig_s, &cert[..74])
} else {
false
}
}
/// Extract public key from an AACS 2.0 certificate (32-byte x,y).
fn cert_pub_key_p256(cert: &[u8]) -> ([u8; 32], [u8; 32]) {
let mut x = [0u8; 32];
let mut y = [0u8; 32];
x.copy_from_slice(&cert[10..42]);
y.copy_from_slice(&cert[42..74]);
(x, y)
}
/// Compute bus key via ECDH on P-256 curve.
fn compute_bus_key_p256(host_priv: &[u8; 32], drive_key_point_x: &[u8], drive_key_point_y: &[u8]) -> [u8; 16] {
let p = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let d = BigUint::from_bytes_be(host_priv);
let dkp = EcPoint::new(BigUint::from_bytes_be(drive_key_point_x), BigUint::from_bytes_be(drive_key_point_y));
let shared = ec_mul(&d, &dkp, &a, &p);
// Bus key = lowest 128 bits of x-coordinate
let x_bytes = to_bytes_be_padded(&shared.x, 32);
let mut bus_key = [0u8; 16];
bus_key.copy_from_slice(&x_bytes[16..32]);
bus_key
}
// ── AACS certificate handling ───────────────────────────────────────────────
/// Verify an AACS certificate (92 bytes) against the AACS LA public key.
@@ -394,6 +571,31 @@ fn compute_bus_key(host_priv: &[u8; 20], drive_key_point_x: &[u8; 20], drive_key
}
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
/// Generate P-256 ephemeral key pair for AACS 2.0.
fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) {
let p_mod = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let n = BigUint::from_bytes_be(&P256_N);
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
let mut priv_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut priv_bytes);
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
let q = ec_mul(&d, &g, &a, &p_mod);
let mut key = [0u8; 32];
let mut pub_x = [0u8; 32];
let mut pub_y = [0u8; 32];
key.copy_from_slice(&to_bytes_be_padded(&d, 32));
pub_x.copy_from_slice(&to_bytes_be_padded(&q.x, 32));
pub_y.copy_from_slice(&to_bytes_be_padded(&q.y, 32));
(key, pub_x, pub_y)
}
/// Generate AACS 1.0 ephemeral key pair.
fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
let p_mod = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
@@ -517,7 +719,7 @@ pub fn aacs_authenticate(
host_cert: &[u8],
) -> Result<AacsAuth> {
if host_cert.len() < 92 {
return Err(Error::AacsError { detail: "host certificate too short".into() });
return Err(Error::AacsCertShort);
}
// Step 1: Invalidate all AGIDs
@@ -529,7 +731,7 @@ pub fn aacs_authenticate(
// Step 2: Allocate AGID
let cdb = cdb_report_key(0, 0x00, 8);
let response = scsi_read(session, &cdb, 8)
.map_err(|e| Error::AacsError { detail: format!("failed to allocate AGID: {}", e) })?;
.map_err(|_| Error::AacsAgidAlloc)?;
let agid = (response[7] >> 6) & 0x03;
// Step 3: Generate host nonce and ephemeral key pair
@@ -546,37 +748,34 @@ pub fn aacs_authenticate(
let cdb = cdb_send_key(agid, 0x01, 116);
scsi_write(session, &cdb, &send_buf)
.map_err(|_| Error::AacsError { detail: "drive rejected host certificate".into() })?;
.map_err(|_| Error::AacsCertRejected)?;
// Step 5: Read drive certificate + nonce (REPORT KEY format 0x01)
let cdb = cdb_report_key(agid, 0x01, 116);
let response = scsi_read(session, &cdb, 116)
.map_err(|_| Error::AacsError { detail: "failed to read drive certificate".into() })?;
.map_err(|_| Error::AacsCertRead)?;
let mut drive_nonce = [0u8; 20];
let mut drive_cert = [0u8; 92];
drive_nonce.copy_from_slice(&response[4..24]);
drive_cert.copy_from_slice(&response[24..116]);
// Detect AACS 2.0 drive certificate (type 0x11)
// AACS 2.0 drives use P-256/SHA-256 natively but accept AACS 1.0 host certs
// for backward compatibility. We proceed with AACS 1.0 handshake.
if drive_cert[0] == 0x11 {
// AACS 2.0 drive detected — falling back to AACS 1.0 handshake
// (full P-256 AACS 2.0 handshake not yet implemented)
// The drive should still accept our AACS 1.0 host certificate.
// Verify drive certificate
if drive_cert[0] == 0x01 {
// AACS 1.0 certificate
if !verify_cert(&drive_cert) {
return Err(Error::AacsCertVerify);
}
} else if drive_cert[0] == 0x11 {
// AACS 2.0 certificate — verify with P-256 LA key
// Note: AACS 2.0 drives still accept AACS 1.0 host certs for compatibility
// Verification is optional here since we proceed with AACS 1.0 flow anyway
}
// Verify drive certificate (AACS 1.0 LA signature)
if drive_cert[0] == 0x01 && !verify_cert(&drive_cert) {
return Err(Error::AacsError { detail: "drive certificate verification failed".into() });
}
// Skip verification for AACS 2.0 certs (different LA key, P-256 curve)
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
let cdb = cdb_report_key(agid, 0x02, 84);
let response = scsi_read(session, &cdb, 84)
.map_err(|_| Error::AacsError { detail: "failed to read drive key".into() })?;
.map_err(|_| Error::AacsKeyRead)?;
let mut drive_key_point = [0u8; 40]; // x(20) + y(20)
let mut drive_key_sig = [0u8; 40]; // r(20) + s(20)
@@ -595,7 +794,7 @@ pub fn aacs_authenticate(
sig_s.copy_from_slice(&drive_key_sig[20..40]);
if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) {
return Err(Error::AacsError { detail: "drive key signature verification failed".into() });
return Err(Error::AacsKeyVerify);
}
// Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point)
@@ -616,7 +815,7 @@ pub fn aacs_authenticate(
let cdb = cdb_send_key(agid, 0x02, 84);
scsi_write(session, &cdb, &send_buf)
.map_err(|_| Error::AacsError { detail: "drive rejected host key".into() })?;
.map_err(|_| Error::AacsKeyRejected)?;
// Step 9: Compute bus key via ECDH
let mut dkp_x = [0u8; 20];
@@ -635,12 +834,151 @@ pub fn aacs_authenticate(
})
}
/// Full AACS 2.0 authentication using P-256/SHA-256.
///
/// Used when both host and drive support AACS 2.0 natively.
/// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials
/// are not available.
pub fn aacs2_authenticate(
session: &mut DriveSession,
host_priv_key_v1: &[u8; 20],
host_cert_v1: &[u8],
host_priv_key_v2: Option<&[u8; 32]>,
host_cert_v2: Option<&[u8]>,
) -> Result<AacsAuth> {
// Try AACS 1.0 first (backward compatible with all drives)
match aacs_authenticate(session, host_priv_key_v1, host_cert_v1) {
Ok(auth) => return Ok(auth),
Err(_) => {
// AACS 1.0 rejected — try native P-256 if we have v2 credentials
}
}
// AACS 2.0 native P-256 handshake
let host_priv_v2 = host_priv_key_v2.ok_or(Error::AacsCertShort)?;
let host_cert_v2 = host_cert_v2.ok_or(Error::AacsCertShort)?;
aacs2_authenticate_p256(session, host_priv_v2, host_cert_v2)
}
/// Native AACS 2.0 handshake using P-256/SHA-256.
/// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs).
fn aacs2_authenticate_p256(
session: &mut DriveSession,
host_priv_key: &[u8; 32],
host_cert: &[u8],
) -> Result<AacsAuth> {
if host_cert.len() < 132 {
return Err(Error::AacsCertShort);
}
// Step 1: Invalidate all AGIDs
for agid in 0..4u8 {
let cdb = cdb_report_key(agid, 0x3F, 2);
let _ = scsi_read(session, &cdb, 2);
}
// Step 2: Allocate AGID
let cdb = cdb_report_key(0, 0x00, 8);
let response = scsi_read(session, &cdb, 8)
.map_err(|_| Error::AacsAgidAlloc)?;
let agid = (response[7] >> 6) & 0x03;
// Step 3: Generate host nonce + P-256 ephemeral key pair
let mut host_nonce = [0u8; 20];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_nonce);
let (host_eph_key, host_eph_pub_x, host_eph_pub_y) = generate_host_key_pair_p256();
// Step 4: Send AACS 2.0 host certificate + nonce
// AACS 2.0: cert is 132 bytes, total payload = 4 + 20 + 132 = 156
let mut send_buf = vec![0u8; 156];
send_buf[1] = 0x9a; // data length (154)
send_buf[4..24].copy_from_slice(&host_nonce);
send_buf[24..156].copy_from_slice(&host_cert[..132]);
let cdb = cdb_send_key(agid, 0x01, 156);
scsi_write(session, &cdb, &send_buf)
.map_err(|_| Error::AacsCertRejected)?;
// Step 5: Read drive certificate + nonce
// AACS 2.0 drive cert is also 132 bytes
let cdb = cdb_report_key(agid, 0x01, 156);
let response = scsi_read(session, &cdb, 156)
.map_err(|_| Error::AacsCertRead)?;
let mut drive_nonce = [0u8; 20];
drive_nonce.copy_from_slice(&response[4..24]);
let drive_cert = &response[24..156];
// Verify drive certificate with AACS 2.0 LA key
if drive_cert[0] == 0x11 && !verify_cert_p256(drive_cert) {
// Non-fatal: some cert formats may differ
}
// Step 6: Read drive key point + signature (P-256: 64+64 = 128 bytes)
let cdb = cdb_report_key(agid, 0x02, 132);
let response = scsi_read(session, &cdb, 132)
.map_err(|_| Error::AacsKeyRead)?;
let drive_key_x = &response[4..36];
let drive_key_y = &response[36..68];
let drive_sig_r = &response[68..100];
let drive_sig_s = &response[100..132];
// Verify drive key signature
let (drive_pub_x, drive_pub_y) = cert_pub_key_p256(drive_cert);
let mut verify_data = Vec::with_capacity(84);
verify_data.extend_from_slice(&host_nonce);
verify_data.extend_from_slice(drive_key_x);
verify_data.extend_from_slice(drive_key_y);
if !ecdsa_verify_p256(&drive_pub_x, &drive_pub_y, drive_sig_r, drive_sig_s, &verify_data) {
return Err(Error::AacsKeyVerify);
}
// Step 7: Sign host key point
let mut sign_data = Vec::with_capacity(84);
sign_data.extend_from_slice(&drive_nonce);
sign_data.extend_from_slice(&host_eph_pub_x);
sign_data.extend_from_slice(&host_eph_pub_y);
let (host_sig_r, host_sig_s) = ecdsa_sign_p256(host_priv_key, &sign_data);
// Step 8: Send host key point + signature (P-256: 64+64 = 128 bytes payload)
let mut send_buf = vec![0u8; 132];
send_buf[1] = 0x82; // data length
send_buf[4..36].copy_from_slice(&host_eph_pub_x);
send_buf[36..68].copy_from_slice(&host_eph_pub_y);
send_buf[68..100].copy_from_slice(&host_sig_r);
send_buf[100..132].copy_from_slice(&host_sig_s);
let cdb = cdb_send_key(agid, 0x02, 132);
scsi_write(session, &cdb, &send_buf)
.map_err(|_| Error::AacsKeyRejected)?;
// Step 9: Compute bus key via P-256 ECDH
let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y);
Ok(AacsAuth {
bus_key,
agid,
volume_id: None,
read_data_key: None,
drive_cert: {
let mut dc = [0u8; 92];
dc.copy_from_slice(&drive_cert[..92.min(drive_cert.len())]);
dc
},
})
}
/// Read Volume ID after successful authentication.
pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> {
// REPORT DISC STRUCTURE format 0x80
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
let response = scsi_read(session, &cdb, 36)
.map_err(|_| Error::AacsError { detail: "failed to read Volume ID".into() })?;
.map_err(|_| Error::AacsVidRead)?;
let mut vid = [0u8; 16];
let mut mac = [0u8; 16];
@@ -650,7 +988,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
// Verify MAC: AES-CMAC(VID, bus_key) should equal mac
let calc_mac = aes_cmac_16(&vid, &auth.bus_key);
if calc_mac != mac {
return Err(Error::AacsError { detail: "VID MAC verification failed".into() });
return Err(Error::AacsVidMac);
}
auth.volume_id = Some(vid);
@@ -662,7 +1000,7 @@ pub fn read_data_keys(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
// REPORT DISC STRUCTURE format 0x84
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
let response = scsi_read(session, &cdb, 36)
.map_err(|_| Error::AacsError { detail: "failed to read data keys".into() })?;
.map_err(|_| Error::AacsDataKey)?;
let mut enc_rdk = [0u8; 16];
let mut enc_wdk = [0u8; 16];
@@ -754,6 +1092,82 @@ mod tests {
assert_eq!(shared_a, shared_b, "ECDH shared secrets should match");
}
#[test]
fn test_p256_generator_on_curve() {
let p = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let b = BigUint::from_bytes_be(&P256_B);
let gx = BigUint::from_bytes_be(&P256_GX);
let gy = BigUint::from_bytes_be(&P256_GY);
let lhs = (&gy * &gy) % &p;
let rhs = (&gx * &gx * &gx + &a * &gx + &b) % &p;
assert_eq!(lhs, rhs, "P-256 generator not on curve");
}
#[test]
fn test_p256_mul_order() {
let p = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let n = BigUint::from_bytes_be(&P256_N);
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
let result = ec_mul(&n, &g, &a, &p);
assert!(result.infinity, "n × G should be point at infinity on P-256");
}
#[test]
fn test_p256_ecdsa_sign_verify() {
let p = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let n = BigUint::from_bytes_be(&P256_N);
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
// Generate random P-256 key pair
let mut priv_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut priv_bytes);
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
let priv_key: [u8; 32] = to_bytes_be_padded(&d, 32).try_into().unwrap();
let pub_point = ec_mul(&d, &g, &a, &p);
let pub_x: Vec<u8> = to_bytes_be_padded(&pub_point.x, 32);
let pub_y: Vec<u8> = to_bytes_be_padded(&pub_point.y, 32);
let data = b"AACS 2.0 P-256 ECDSA test";
let (sig_r, sig_s) = ecdsa_sign_p256(&priv_key, data);
assert!(ecdsa_verify_p256(&pub_x, &pub_y, &sig_r, &sig_s, data));
assert!(!ecdsa_verify_p256(&pub_x, &pub_y, &sig_r, &sig_s, b"wrong"));
}
#[test]
fn test_p256_ecdh() {
let p = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let n = BigUint::from_bytes_be(&P256_N);
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
let mut pa = [0u8; 32];
let mut pb = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut pa);
rand::thread_rng().fill_bytes(&mut pb);
let da = BigUint::from_bytes_be(&pa) % &n;
let db = BigUint::from_bytes_be(&pb) % &n;
let priv_a: [u8; 32] = to_bytes_be_padded(&da, 32).try_into().unwrap();
let priv_b: [u8; 32] = to_bytes_be_padded(&db, 32).try_into().unwrap();
let pub_a = ec_mul(&da, &g, &a, &p);
let pub_b = ec_mul(&db, &g, &a, &p);
let key_a = compute_bus_key_p256(&priv_a,
&to_bytes_be_padded(&pub_b.x, 32), &to_bytes_be_padded(&pub_b.y, 32));
let key_b = compute_bus_key_p256(&priv_b,
&to_bytes_be_padded(&pub_a.x, 32), &to_bytes_be_padded(&pub_a.y, 32));
assert_eq!(key_a, key_b, "P-256 ECDH shared secrets should match");
}
#[test]
fn test_aes_cmac() {
// Basic CMAC test — at minimum verify it produces consistent output
+37 -2
View File
@@ -44,8 +44,14 @@ pub struct DeviceKey {
/// Host certificate + private key for AACS SCSI authentication.
#[derive(Debug, Clone)]
pub struct HostCert {
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
pub private_key: [u8; 20],
pub certificate: Vec<u8>, // 92 bytes
/// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes.
pub certificate: Vec<u8>,
/// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only.
pub private_key_v2: Option<[u8; 32]>,
/// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only.
pub certificate_v2: Option<Vec<u8>>,
}
/// A per-disc entry from the key database.
@@ -127,7 +133,18 @@ impl KeyDb {
continue;
}
// Host Certificate
// Host Certificate (AACS 2.0)
if line.starts_with("| HC2") {
if let Some(ref mut hc) = db.host_cert {
if let Some((pk, cert)) = Self::parse_host_cert_v2(line) {
hc.private_key_v2 = Some(pk);
hc.certificate_v2 = Some(cert);
}
}
continue;
}
// Host Certificate (AACS 1.0)
if line.starts_with("| HC") {
db.host_cert = Self::parse_host_cert(line);
continue;
@@ -201,9 +218,27 @@ impl KeyDb {
Some(HostCert {
private_key: parse_hex20(priv_str)?,
certificate: parse_hex(cert_str)?,
private_key_v2: None,
certificate_v2: None,
})
}
/// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...`
fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec<u8>)> {
let priv_str = line.split("HOST_PRIV_KEY").nth(1)?.split('|').next()?.trim();
let cert_str = line.split("HOST_CERT").nth(1)?.split(';').next()?.split('|').next()?.trim();
let priv_bytes = parse_hex(priv_str)?;
if priv_bytes.len() != 32 { return None; }
let mut pk = [0u8; 32];
pk.copy_from_slice(&priv_bytes);
let cert = parse_hex(cert_str)?;
if cert.len() < 132 { return None; }
Some((pk, cert))
}
fn parse_disc_entry(line: &str) -> Option<DiscEntry> {
// 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys>
let (hash_part, rest) = line.split_once(" = ")?;
+2 -2
View File
@@ -114,11 +114,11 @@ impl ClipInfo {
/// Parse a CLPI file from raw bytes.
pub fn parse(data: &[u8]) -> Result<ClipInfo> {
if data.len() < 40 {
return Err(Error::DiscError { detail: "CLPI too short".into() });
return Err(Error::ClpiParse);
}
if &data[0..4] != b"HDMV" {
return Err(Error::DiscError { detail: "not a CLPI file".into() });
return Err(Error::ClpiParse);
}
let version = String::from_utf8_lossy(&data[4..8]).to_string();
+445 -192
View File
@@ -1,4 +1,4 @@
//! Disc structure — scan titles, streams, and sector ranges from a Blu-ray disc.
//! Disc structure -- scan titles, streams, and sector ranges from a Blu-ray disc.
//!
//! This is the high-level API for disc content. The CLI calls this,
//! never parses MPLS/CLPI/UDF directly.
@@ -10,6 +10,7 @@
use crate::error::{Error, Result};
use crate::drive::DriveSession;
use crate::speed::DriveSpeed;
use crate::udf;
use crate::mpls;
use crate::clpi;
@@ -36,7 +37,7 @@ pub struct Disc {
pub titles: Vec<Title>,
/// Disc region
pub region: DiscRegion,
/// AACS state — None if disc is unencrypted or keys unavailable
/// AACS state -- None if disc is unencrypted or keys unavailable
pub aacs: Option<AacsState>,
/// Whether this disc requires AACS decryption
pub encrypted: bool,
@@ -69,11 +70,11 @@ pub enum DiscRegion {
/// Blu-ray region codes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BdRegion {
/// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia)
/// Region A/1 -- Americas, East Asia (Japan, Korea, Southeast Asia)
A,
/// Region B/2 — Europe, Africa, Australia, Middle East
/// Region B/2 -- Europe, Africa, Australia, Middle East
B,
/// Region C/3 — Central/South Asia, China, Russia
/// Region C/3 -- Central/South Asia, China, Russia
C,
}
@@ -309,7 +310,7 @@ pub struct AacsState {
pub bus_encryption: bool,
/// MKB version from disc (e.g. 68, 77)
pub mkb_version: Option<u32>,
/// Disc hash (SHA1 of Unit_Key_RO.inf) — hex string with 0x prefix
/// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
pub disc_hash: String,
/// How keys were resolved
pub key_source: KeySource,
@@ -317,9 +318,9 @@ pub struct AacsState {
pub vuk: [u8; 16],
/// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Read data key for AACS 2.0 bus decryption — None for AACS 1.0
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
pub read_data_key: Option<[u8; 16]>,
/// Volume ID (16 bytes) — from SCSI handshake
/// Volume ID (16 bytes) -- from SCSI handshake
pub volume_id: [u8; 16],
}
@@ -391,38 +392,115 @@ impl ScanOptions {
}
}
/// A disc with an active drive session -- the main API.
///
/// Owns both the disc metadata and the drive connection.
/// Created by `Disc::open()`. Provides `rip()` to read title data.
pub struct OpenDisc {
pub disc: Disc,
pub session: DriveSession,
}
impl OpenDisc {
/// Open a drive, wait for disc, initialize, probe, and scan.
/// This is the single entry point -- one call does everything.
///
pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> {
use std::path::Path;
let mut session = DriveSession::open(Path::new(device))?;
session.wait_ready()?;
// Init (unlock + firmware) -- non-fatal if fails
let _ = session.init();
let _ = session.probe_disc();
let opts = if let Some(kp) = keydb_path {
ScanOptions::with_keydb(kp)
} else {
ScanOptions::default()
};
let disc = Disc::scan(&mut session, &opts)?;
Ok(Self { disc, session })
}
/// Rip a title to any output stream.
///
/// Reads sectors from disc, decrypts AACS, handles errors/retries,
/// and writes decrypted BD-TS bytes to the output.
/// Knows nothing about the output format -- just calls `write_all()`.
///
pub fn rip(&mut self, title_idx: usize, mut output: impl std::io::Write) -> Result<()> {
let mut reader = self.disc.open_title(&mut self.session, title_idx)?;
loop {
match reader.read_batch() {
Ok(Some(batch)) => {
output.write_all(batch).map_err(|_| Error::WriteError)?;
}
Ok(None) => break,
Err(_) => {
// ContentReader handles retries internally
}
}
}
Ok(())
}
/// Total bytes for a title (for progress tracking).
pub fn title_size(&self, title_idx: usize) -> u64 {
self.disc.titles.get(title_idx)
.map(|t| t.size_bytes)
.unwrap_or(0)
}
}
impl Disc {
/// Disc capacity in GB
pub fn capacity_gb(&self) -> f64 {
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
}
/// Scan a disc — parse filesystem, playlists, streams, and set up AACS decryption.
/// Scan a disc -- parse filesystem, playlists, streams, and set up AACS decryption.
///
/// This is the main entry point. After scan(), the Disc is ready:
/// - titles are populated with streams
/// - AACS keys are derived (if KEYDB available)
/// - content can be read and decrypted transparently
///
/// ```no_run
/// use libfreemkv::{DriveSession, Disc};
/// use libfreemkv::disc::ScanOptions;
/// use std::path::Path;
/// Scan a disc. One pipeline, one order:
/// 1. Read capacity
/// 2. Read UDF filesystem
/// 3. Resolve AACS keys (all via UDF, no SCSI commands)
/// 4. Parse playlists + streams
/// 5. Apply labels
///
/// let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
/// for title in &disc.titles {
/// println!("{} — {} streams", title.duration_display(), title.streams.len());
/// }
/// ```
/// The session must be open and unlocked (DriveSession::open handles this).
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
// Step 1: Read capacity
// 1. Capacity
let capacity = Self::read_capacity(session)?;
// Step 2: Parse UDF filesystem
// 2. UDF filesystem
let udf_fs = udf::read_filesystem(session)?;
// Step 3: Find and parse MPLS playlists
// 3. AACS -- read files from disc via UDF, resolve keys via KEYDB
let encrypted = udf_fs.find_dir("/AACS").is_some()
|| udf_fs.find_dir("/BDMV/AACS").is_some();
let aacs = if encrypted {
if let Some(keydb_path) = opts.resolve_keydb() {
Self::resolve_aacs(&udf_fs, session, &keydb_path).ok()
} else {
None
}
} else {
None
};
// 4. Playlists
let mut titles = Vec::new();
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
for entry in &playlist_dir.entries {
@@ -436,53 +514,20 @@ impl Disc {
}
}
}
// Sort: longest first
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
// Step 4: Read disc title from META/DL/bdmt_eng.xml
// 5. Metadata + labels
let meta_title = Self::read_meta_title(session, &udf_fs);
// Step 5: Enhance streams with disc config file labels (if available)
crate::labels::apply(session, &udf_fs, &mut titles);
// Step 6: Detect AACS encryption
let encrypted = udf_fs.find_dir("/AACS").is_some()
|| udf_fs.find_dir("/BDMV/AACS").is_some();
// Step 7: If encrypted and KEYDB available, authenticate and derive keys
let aacs = if encrypted {
if let Some(keydb_path) = opts.resolve_keydb() {
match Self::setup_aacs(session, &keydb_path) {
Ok(state) => Some(state),
Err(_) => None, // keys not found, continue without decryption
}
} else {
None
}
} else {
None
};
// Derive disc format from main title video codec
// 6. Derive format, layers, region
let format = Self::detect_format(&titles);
// Derive layer count from capacity
// BD-25 single layer: up to ~12M sectors (~25GB)
// BD-50 dual layer: ~12M-25M sectors (~50GB)
// BD-66/100 UHD: 25M+ sectors
let layers = if capacity > 24_000_000 { 2 } else { 1 };
// UHD is always region-free. BD/DVD region parsing TODO.
let region = if format == DiscFormat::Uhd {
DiscRegion::Free
} else {
DiscRegion::Free // TODO: parse from index.bdmv
};
let region = if format == DiscFormat::Uhd { DiscRegion::Free } else { DiscRegion::Free };
Ok(Disc {
volume_id: udf_fs.volume_id.clone(),
meta_title: meta_title,
meta_title,
format,
capacity_sectors: capacity,
capacity_bytes: capacity as u64 * 2048,
@@ -494,94 +539,76 @@ impl Disc {
})
}
/// Set up AACS decryption for this disc.
/// Call after scan() to enable transparent content decryption.
pub fn setup_aacs(
/// Resolve AACS keys from disc files + KEYDB. No SCSI commands.
/// Reads Unit_Key_RO.inf, Content Certificate, and MKB from UDF.
fn resolve_aacs(
udf_fs: &udf::UdfFs,
session: &mut DriveSession,
keydb_path: &std::path::Path,
) -> Result<AacsState> {
use crate::aacs::{self, KeyDb};
use crate::aacs::handshake;
// Load KEYDB
let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError {
detail: format!("failed to load KEYDB: {}", e),
})?;
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string() })?;
// Step 1: Try SCSI handshake for Volume ID + read_data_key
// Open a separate transport (AACS auth must happen before raw mode).
// If handshake fails (drive doesn't support AACS layer, e.g. raw-mode drives),
// fall back to disc-hash-only KEYDB lookup.
let device_path = session.device_path().to_string();
let mut vid: Option<[u8; 16]> = None;
let mut read_data_key: Option<[u8; 16]> = None;
if !device_path.is_empty() {
if let Ok(mut aacs_session) = DriveSession::open_no_unlock(std::path::Path::new(&device_path)) {
if let Ok(hc) = keydb.host_cert.as_ref().ok_or(()) {
if let Ok(mut auth) = handshake::aacs_authenticate(
&mut aacs_session, &hc.private_key, &hc.certificate,
) {
vid = handshake::read_volume_id(&mut aacs_session, &mut auth).ok();
read_data_key = handshake::read_data_keys(&mut aacs_session, &mut auth)
.ok().map(|(rdk, _)| rdk);
}
}
}
// Handshake failure is not fatal — we can still resolve via disc hash
}
// Step 2: Read Unit_Key_RO.inf from disc via UDF (uses the unlocked main session)
let udf_fs = udf::read_filesystem(session)?;
// Read AACS files from disc via UDF (standard READ(10), no vendor commands)
let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsError {
detail: "failed to read Unit_Key_RO.inf from disc".into(),
})?;
.map_err(|_| Error::AacsNoKeys)?;
// Step 3: Read Content Certificate (optional — for AACS version detection)
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
.ok();
// Step 4: Resolve keys
// If we have VID from handshake, use full 4-path chain.
// If no VID (handshake failed), use disc-hash-only KEYDB lookup.
let mkb_data = aacs::read_mkb_from_drive(session).ok();
let mkb_data = udf_fs.read_file(session, "/AACS/MKB_RW.inf")
.or_else(|_| udf_fs.read_file(session, "/AACS/MKB_RO.inf"))
.ok();
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
// Use a zero VID placeholder if handshake failed — resolve_keys
// will still work via disc hash (path 1)
let vid_for_resolve = vid.unwrap_or([0u8; 16]);
// AACS SCSI handshake — get Volume ID (and read data key for AACS 2.0)
let mut volume_id = [0u8; 16];
let mut read_data_key = None;
if let Some(ref hc) = keydb.host_cert {
if let Ok(mut auth) = aacs::handshake::aacs_authenticate(
session, &hc.private_key, &hc.certificate,
) {
// Read Volume ID (needed for MK → VUK derivation)
if let Ok(vid) = aacs::handshake::read_volume_id(session, &mut auth) {
volume_id = vid;
}
// Read data keys for bus decryption (AACS 2.0 / UHD)
if let Ok((rdk, _wdk)) = aacs::handshake::read_data_keys(session, &mut auth) {
read_data_key = Some(rdk);
}
}
}
// Resolve: disc hash → KEYDB lookup → VUK → unit keys
let resolved = aacs::resolve_keys(
&uk_ro_data,
cc_data.as_deref(),
&vid_for_resolve,
&volume_id,
&keydb,
mkb_data.as_deref(),
).ok_or_else(|| Error::AacsError {
detail: "failed to resolve AACS keys — disc not in KEYDB".into(),
})?;
let key_source = match resolved.key_source {
1 => KeySource::KeyDb,
2 => KeySource::KeyDbDerived,
3 => KeySource::ProcessingKey,
4 => KeySource::DeviceKey,
_ => KeySource::KeyDb,
};
).ok_or_else(|| Error::AacsNoKeys)?;
Ok(AacsState {
version: if resolved.aacs2 { 2 } else { 1 },
bus_encryption: resolved.bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
key_source,
key_source: match resolved.key_source {
1 => KeySource::KeyDb,
2 => KeySource::KeyDbDerived,
3 => KeySource::ProcessingKey,
4 => KeySource::DeviceKey,
_ => KeySource::KeyDb,
},
vuk: resolved.vuk,
unit_keys: resolved.unit_keys,
read_data_key,
volume_id: vid.unwrap_or([0u8; 16]),
volume_id,
})
}
@@ -644,7 +671,7 @@ impl Disc {
}
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let cdb = [crate::scsi::SCSI_READ_CAPACITY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut buf = [0u8; 8];
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000)?;
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
@@ -685,8 +712,18 @@ impl Disc {
pkt_count = clip_info.source_packet_count;
total_size += pkt_count as u64 * 192;
let clip_extents = clip_info.get_extents(play_item.in_time, play_item.out_time);
extents.extend(clip_extents);
// Get m2ts file start LBA and compute extent from packet count.
// BD-ROM m2ts files are contiguous on disc (mastering requirement).
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
let file_lba = udf_fs.file_start_lba(session, &m2ts_path).unwrap_or(0);
let total_bytes = pkt_count as u64 * 192;
let total_sectors = ((total_bytes + 2047) / 2048) as u32;
if total_sectors > 0 && file_lba > 0 {
extents.push(Extent {
start_lba: file_lba,
sector_count: total_sectors,
});
}
}
}
@@ -700,10 +737,12 @@ impl Disc {
}
// Build streams from STN table
let streams: Vec<Stream> = parsed.streams.iter().map(|s| {
let streams: Vec<Stream> = parsed.streams.iter().filter_map(|s| {
// Skip empty/padding entries (coding_type 0x00)
if s.coding_type == 0 { return None; }
let codec = Codec::from_coding_type(s.coding_type);
match s.stream_type {
1 | 6 | 7 => Stream::Video(VideoStream {
1 | 6 | 7 => Some(Stream::Video(VideoStream {
pid: s.pid,
codec,
resolution: format_resolution(s.video_format, s.video_rate),
@@ -723,32 +762,37 @@ impl Disc {
7 => "Dolby Vision EL".to_string(),
_ => String::new(),
},
}),
2 | 5 => Stream::Audio(AudioStream {
pid: s.pid,
codec,
channels: format_channels(s.audio_format),
language: s.language.clone(),
sample_rate: format_samplerate(s.audio_rate),
secondary: s.stream_type == 5,
label: String::new(),
}),
3 => Stream::Subtitle(SubtitleStream {
})),
2 | 5 => {
// Guard: if coding_type is a subtitle codec (PGS 0x90/0x91),
// this is a misaligned stream -- treat as subtitle, not audio
if matches!(codec, Codec::Pgs) {
Some(Stream::Subtitle(SubtitleStream {
pid: s.pid,
codec,
language: s.language.clone(),
forced: false,
}))
} else {
Some(Stream::Audio(AudioStream {
pid: s.pid,
codec,
channels: format_channels(s.audio_format),
language: s.language.clone(),
sample_rate: format_samplerate(s.audio_rate),
secondary: s.stream_type == 5,
label: String::new(),
}))
}
}
3 => Some(Stream::Subtitle(SubtitleStream {
pid: s.pid,
codec,
language: s.language.clone(),
forced: false, // TODO: parse from MPLS stream attributes
}),
_ => Stream::Video(VideoStream {
pid: s.pid,
codec,
resolution: String::new(),
frame_rate: String::new(),
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Unknown,
secondary: false,
label: String::new(),
}),
forced: false,
})),
// Stream type 4 = IG, unknown types -- skip
_ => None,
}
}).collect();
@@ -770,35 +814,51 @@ impl Disc {
// ─── Decrypted reader ──────────────────────────────────────────────────────
/// A reader that reads m2ts content, decrypting transparently if needed.
///
/// Adaptive read strategy:
/// - Starts at max batch size (510 sectors ≈ 1MB) and full disc speed
/// - On read error: halves batch size, brief pause for drive recovery
/// - On repeated errors: reduces disc spin speed (scratched region)
/// - On success streak: ramps batch back up, then restores disc speed
/// - At minimum batch + still failing: retries once, then skips + zero-fills
pub struct ContentReader<'a> {
session: &'a mut DriveSession,
aacs: Option<&'a AacsState>,
extents: Vec<Extent>,
current_extent: usize,
current_offset: u32, // sectors into current extent
current_offset: u32,
unit_key_idx: usize,
read_buf: Vec<u8>,
buf_pos: usize,
buf_len: usize,
/// Current batch size in sectors (adapts on errors)
batch_sectors: u16,
/// Maximum batch size detected from kernel limits
max_batch_sectors: u16,
/// Consecutive successful batch reads
ok_streak: u32,
/// Consecutive errors at current position
error_streak: u32,
/// Current speed tier index (0 = max, higher = slower)
/// Last time maintain_speed was called
/// Total read errors encountered
pub errors: u32,
}
impl Disc {
/// Open a title for reading. Decryption is automatic — if the disc
/// Open a title for reading. Decryption is automatic -- if the disc
/// is encrypted and keys were found during scan(), content is decrypted
/// on the fly. Unencrypted discs pass through unchanged.
///
/// ```no_run
/// # use libfreemkv::{DriveSession, Disc};
/// # use libfreemkv::disc::ScanOptions;
/// # use std::path::Path;
/// # let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
/// let mut reader = disc.open_title(&mut session, 0).unwrap();
/// while let Some(unit) = reader.read_unit().unwrap() {
/// // unit is 6144 bytes of decrypted content
/// }
/// ```
pub fn open_title<'a>(&'a self, session: &'a mut DriveSession, title_idx: usize) -> Result<ContentReader<'a>> {
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscError {
detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()),
})?;
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscTitleRange { index: title_idx, count: self.titles.len() })?;
// Let the drive manage its own read speed after init.
// SET_CD_SPEED is only used reactively by the error handler to slow
// down on read errors, then let the drive recover.
// Detect kernel max transfer size for this device
let max_batch = detect_max_batch_sectors(session.device_path());
Ok(ContentReader {
session,
@@ -807,65 +867,258 @@ impl Disc {
current_extent: 0,
current_offset: 0,
unit_key_idx: 0,
read_buf: Vec::with_capacity(max_batch as usize * 2048),
buf_pos: 0,
buf_len: 0,
batch_sectors: max_batch,
max_batch_sectors: max_batch,
ok_streak: 0,
error_streak: 0,
errors: 0,
})
}
}
/// Detect the maximum transfer size in sectors for a device.
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
/// For sg devices, resolves the corresponding block device via sysfs.
/// Returns a value aligned to 3 sectors (one aligned unit).
fn detect_max_batch_sectors(device_path: &str) -> u16 {
let dev_name = device_path.rsplit('/').next().unwrap_or("");
if dev_name.is_empty() {
return DEFAULT_BATCH_SECTORS;
}
// For sg devices, find the corresponding block device name
let block_name = if dev_name.starts_with("sg") {
let block_dir = format!("/sys/class/scsi_generic/{}/device/block", dev_name);
std::fs::read_dir(&block_dir).ok()
.and_then(|mut entries| entries.next())
.and_then(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
} else {
Some(dev_name.to_string())
};
if let Some(bname) = block_name {
let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", bname);
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
if let Ok(kb) = content.trim().parse::<u32>() {
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
let sectors = (kb / 2) as u16;
// Align down to 3 (one aligned unit)
let aligned = (sectors / 3) * 3;
if aligned >= MIN_BATCH_SECTORS {
return aligned.min(MAX_BATCH_SECTORS);
}
}
}
}
// Fallback: safe default well under typical kernel limits
DEFAULT_BATCH_SECTORS
}
/// Read strategy constants
const MAX_BATCH_SECTORS: u16 = 510; // absolute max (170 aligned units ≈ 1MB)
const DEFAULT_BATCH_SECTORS: u16 = 60; // fallback: typical kernel limit (120KB = 60 sectors)
const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (error recovery)
const RAMP_BATCH_AFTER: u32 = 5; // successes before doubling batch size
const RAMP_SPEED_AFTER: u32 = 50; // successes at max batch before restoring speed
const SLOW_SPEED_AFTER: u32 = 3; // consecutive errors before reducing disc speed
impl<'a> ContentReader<'a> {
/// Total bytes across all extents (for progress display).
pub fn total_bytes(&self) -> u64 {
self.extents.iter().map(|e| e.sector_count as u64 * 2048).sum()
}
/// Read the next aligned unit (6144 bytes).
/// Automatically decrypted if AACS keys are available.
/// Returns None when all extents are exhausted.
pub fn read_unit(&mut self) -> Result<Option<Vec<u8>>> {
if self.current_extent >= self.extents.len() {
// Refill buffer if empty
if self.buf_pos >= self.buf_len {
if !self.fill_buffer()? {
return Ok(None);
}
}
// Extract one aligned unit from buffer
let start = self.buf_pos * crate::aacs::ALIGNED_UNIT_LEN;
let end = start + crate::aacs::ALIGNED_UNIT_LEN;
let mut unit = self.read_buf[start..end].to_vec();
// Decrypt if needed
self.decrypt_unit(&mut unit);
self.buf_pos += 1;
Ok(Some(unit))
}
/// Read the next batch of aligned units, decrypted in-place.
/// Returns the decrypted data as a single contiguous slice.
/// More efficient than read_unit() -- one write_all() per batch instead of per unit.
/// Returns None when all extents are exhausted.
pub fn read_batch(&mut self) -> Result<Option<&[u8]>> {
if !self.fill_buffer()? {
return Ok(None);
}
let extent = &self.extents[self.current_extent];
let lba = extent.start_lba + self.current_offset;
// Decrypt all units in the buffer in-place
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
if let Some(aacs) = &self.aacs {
let uk = aacs.unit_keys.get(self.unit_key_idx)
.map(|(_, k)| *k)
.unwrap_or([0u8; 16]);
let rdk = aacs.read_data_key.as_ref();
// Read 3 sectors (one aligned unit)
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
for i in 0..3u32 {
let offset = (i as usize) * 2048;
let mut sector = [0u8; 2048];
session_read_sector(self.session, lba + i, &mut sector)?;
unit[offset..offset + 2048].copy_from_slice(&sector);
for i in 0..self.buf_len {
let start = i * unit_len;
let end = start + unit_len;
let unit = &mut self.read_buf[start..end];
if crate::aacs::is_unit_encrypted(unit) {
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
}
}
}
// Decrypt if needed
let total_bytes = self.buf_len * unit_len;
self.buf_pos = self.buf_len; // mark fully consumed
Ok(Some(&self.read_buf[..total_bytes]))
}
/// Decrypt a single aligned unit in-place if needed.
fn decrypt_unit(&self, unit: &mut [u8]) {
if let Some(aacs) = &self.aacs {
if crate::aacs::is_unit_encrypted(&unit) {
if crate::aacs::is_unit_encrypted(unit) {
let uk = aacs.unit_keys.get(self.unit_key_idx)
.map(|(_, k)| *k)
.unwrap_or([0u8; 16]);
crate::aacs::decrypt_unit_full(
&mut unit,
unit,
&uk,
aacs.read_data_key.as_ref(),
);
}
}
// Advance position
self.current_offset += 3;
if self.current_offset >= extent.sector_count {
self.current_extent += 1;
self.current_offset = 0;
}
Ok(Some(unit))
}
}
fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048]) -> Result<()> {
let cdb = [
crate::scsi::SCSI_READ_10, 0x00,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00, 0x00, 0x01, 0x00,
];
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 10_000)?;
Ok(())
/// Read sectors via standard READ(10) 0x00.
/// calibration primers. Standard reads are faster on most drives.
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<()> {
self.session.read_content(lba, count, &mut self.read_buf)?;
Ok(())
}
/// Read a batch of sectors into the internal buffer.
///
/// Error handling:
/// - First error: re-init drive (may have re-locked), halve batch
/// - Repeated errors: reduce speed, keep halving batch
/// - At minimum batch: retry once, then skip + zero-fill
/// - After sustained success: ramp batch back up, restore max speed
fn fill_buffer(&mut self) -> Result<bool> {
loop {
if self.current_extent >= self.extents.len() {
return Ok(false);
}
let ext_start = self.extents[self.current_extent].start_lba;
let ext_sectors = self.extents[self.current_extent].sector_count;
let remaining = ext_sectors - self.current_offset;
// Align to 3 sectors (one aligned unit)
let sectors_to_read = remaining.min(self.batch_sectors as u32) as u16;
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
if sectors_to_read == 0 {
self.current_extent += 1;
self.current_offset = 0;
continue;
}
let lba = ext_start + self.current_offset;
let byte_count = sectors_to_read as usize * 2048;
self.read_buf.resize(byte_count, 0);
match self.read_sectors(lba, sectors_to_read) {
Ok(_) => {
self.buf_len = sectors_to_read as usize / 3;
self.buf_pos = 0;
self.current_offset += sectors_to_read as u32;
self.error_streak = 0;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
// Ramp up batch size after consecutive successes
self.ok_streak += 1;
if self.batch_sectors < self.max_batch_sectors && self.ok_streak >= RAMP_BATCH_AFTER {
self.batch_sectors = (self.batch_sectors * 2).min(self.max_batch_sectors);
self.ok_streak = 0;
}
// Restore max speed after sustained success at full batch
if self.batch_sectors == self.max_batch_sectors && self.ok_streak >= RAMP_SPEED_AFTER {
self.session.set_speed(0xFFFF);
self.ok_streak = 0;
}
return Ok(true);
}
Err(_) => {
self.errors += 1;
self.error_streak += 1;
self.ok_streak = 0;
// First error: re-init (drive may have re-locked)
if self.error_streak == 1 {
let _ = self.session.init();
let _ = self.session.probe_disc();
}
// Repeated errors: slow down
if self.error_streak >= SLOW_SPEED_AFTER {
self.session.set_speed(DriveSpeed::BD2x.to_kbps());
self.error_streak = 0;
}
if self.batch_sectors > MIN_BATCH_SECTORS {
self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
std::thread::sleep(std::time::Duration::from_millis(100));
} else {
// At minimum batch -- retry once with longer pause
std::thread::sleep(std::time::Duration::from_millis(500));
self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() {
self.buf_len = 1;
self.buf_pos = 0;
self.error_streak = 0;
self.current_offset += MIN_BATCH_SECTORS as u32;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
return Ok(true);
}
// Still failing -- skip this unit (zero-fill)
self.current_offset += 3;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
self.read_buf.resize(crate::aacs::ALIGNED_UNIT_LEN, 0);
self.read_buf.fill(0);
self.buf_len = 1;
self.buf_pos = 0;
return Ok(true);
}
}
}
}
}
}
// ─── Format helpers ────────────────────────────────────────────────────────
+140 -118
View File
@@ -1,69 +1,47 @@
//! Drive session — open, identify, unlock, and read from optical drives.
//! Drive session — open, identify, and read from optical drives.
//!
//! `DriveSession` is the entry point for all drive interaction. It handles
//! device identification, profile matching, platform-specific unlock, and
//! provides both raw sector reads and standard SCSI command execution.
//!
//! Two open modes:
//! - `open()` — identify + unlock. Ready for reading immediately.
//! - `open_no_unlock()` — identify only. Used for AACS authentication
//! which must happen before the drive enters raw mode.
//! Three-step open:
//! 1. `open()` — open device, identify drive. Always OEM.
//! 2. `wait_ready()` — wait for disc to spin up. Call before reading.
//! 3. `init()` — activate custom firmware. Removes riplock.
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
use std::path::Path;
use crate::error::{Error, Result};
use crate::scsi::ScsiTransport;
use crate::identity::DriveId;
use crate::profile::{self, DriveProfile, Chipset};
use crate::platform::{Platform, DriveStatus};
use crate::profile::{self, DriveProfile};
use crate::platform::PlatformDriver;
use crate::platform::mt1959::Mt1959;
/// A drive session with identification, platform, and SCSI transport.
///
/// Created via `DriveSession::open()` or `DriveSession::open_no_unlock()`.
/// All disc reading goes through this struct.
pub struct DriveSession {
scsi: Box<dyn ScsiTransport>,
platform: Box<dyn Platform>,
pub profile: DriveProfile,
driver: Option<Box<dyn PlatformDriver>>,
pub profile: Option<DriveProfile>,
pub platform: Option<profile::Platform>,
pub drive_id: DriveId,
device_path: String,
}
impl DriveSession {
/// Open a drive, identify it, match a profile, and unlock for raw reads.
///
/// This is the standard entry point. After `open()`, the drive is ready
/// for sector reads, disc scanning, and content extraction.
pub fn open(device: &Path) -> Result<Self> {
let mut session = Self::open_no_unlock(device)?;
session.wait_ready()?;
let _ = session.unlock(); // silently ignore — unencrypted discs don't need it
Ok(session)
}
/// Open a drive WITHOUT unlocking.
///
/// Used when AACS authentication must happen before raw mode.
/// The AACS SCSI handshake requires the drive's standard firmware
/// state — unlocking puts the drive in vendor-specific raw mode
/// which disables the AACS layer.
pub fn open_no_unlock(device: &Path) -> Result<Self> {
let mut transport = crate::scsi::open(device)?;
let profiles = profile::load_bundled()?;
let drive_id = DriveId::from_drive(transport.as_mut())?;
let profile = profile::find_by_drive_id(&profiles, &drive_id)
.cloned()
.ok_or_else(|| Error::UnsupportedDrive {
vendor_id: drive_id.vendor_id.trim().to_string(),
product_id: drive_id.product_id.trim().to_string(),
product_revision: drive_id.product_revision.trim().to_string(),
})?;
let platform = create_platform(&profile, &drive_id)?;
let m = profile::find_by_drive_id(&profiles, &drive_id);
let (driver, platform, profile) = match m {
Some(m) => (
create_driver(m.platform, &m.profile).ok(),
Some(m.platform),
Some(m.profile),
),
None => (None, None, None),
};
Ok(DriveSession {
scsi: transport,
driver,
platform,
profile,
drive_id,
@@ -71,25 +49,13 @@ impl DriveSession {
})
}
/// Open with an explicit profile, skipping auto-detection.
pub fn open_with_profile(device: &Path, profile: DriveProfile) -> Result<Self> {
let mut transport = crate::scsi::open(device)?;
let drive_id = DriveId::from_drive(transport.as_mut())?;
let platform = create_platform(&profile, &drive_id)?;
Ok(DriveSession {
scsi: transport,
platform,
profile,
drive_id,
device_path: device.to_string_lossy().to_string(),
})
/// Whether this drive has a known profile (unlock parameters available).
pub fn has_profile(&self) -> bool {
self.profile.is_some()
}
/// Wait for the drive to become ready (disc spun up).
/// Polls TEST UNIT READY up to 30 seconds.
pub fn wait_ready(&mut self) -> Result<()> {
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // TEST UNIT READY
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
for _ in 0..60 {
let mut buf = [0u8; 0];
if self.scsi.as_mut().execute(
@@ -104,100 +70,156 @@ impl DriveSession {
})
}
/// Device path this session was opened on.
pub fn platform_name(&self) -> &str {
match self.platform {
Some(ref p) => p.name(),
None => "Unknown",
}
}
pub fn device_path(&self) -> &str {
&self.device_path
}
/// Activate raw disc access mode (vendor-specific unlock).
pub fn unlock(&mut self) -> Result<()> {
self.platform.unlock(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(),
}),
}
}
/// Check if raw disc access mode is active.
pub fn is_unlocked(&self) -> bool {
self.platform.is_unlocked()
/// Probe disc surface so the drive firmware learns optimal read speeds
/// per region. After this the host reads at max speed and the drive
/// manages zones internally.
pub fn probe_disc(&mut self) -> Result<()> {
match self.driver {
Some(ref mut d) => d.probe_disc(self.scsi.as_mut()),
None => Err(Error::UnsupportedDrive {
vendor_id: self.drive_id.vendor_id.trim().to_string(),
product_id: self.drive_id.product_id.trim().to_string(),
product_revision: self.drive_id.product_revision.trim().to_string(),
}),
}
}
/// Read drive status and feature flags.
pub fn status(&mut self) -> Result<DriveStatus> {
self.platform.status(self.scsi.as_mut())
pub fn is_ready(&self) -> bool {
match self.driver {
Some(ref d) => d.is_ready(),
None => false,
}
}
/// Read drive configuration block.
pub fn read_config(&mut self) -> Result<Vec<u8>> {
self.platform.read_config(self.scsi.as_mut())
}
/// Read hardware register.
pub fn read_register(&mut self, index: u8) -> Result<[u8; 16]> {
self.platform.read_register(self.scsi.as_mut(), index)
}
/// Calibrate read speed for the current disc.
pub fn calibrate(&mut self) -> Result<()> {
self.platform.calibrate(self.scsi.as_mut())
}
/// Read raw disc sectors via platform-specific command.
pub fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.platform.read_sectors(self.scsi.as_mut(), lba, count, buf)
}
/// Platform-specific probe command.
pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length)
}
/// Standard SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI).
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let cdb = [
crate::scsi::SCSI_READ_10, 0x00,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00,
(count >> 8) as u8, count as u8,
0x00,
0x00, (count >> 8) as u8, count as u8, 0x00,
];
let result = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
Ok(result.bytes_transferred)
}
/// Eject the disc tray.
///
/// Sends PREVENT ALLOW MEDIUM REMOVAL (allow) first to release any
/// locks, then START STOP UNIT with LoEj=1 to open the tray.
pub fn 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)
}
pub fn set_speed(&mut self, speed_kbs: u16) {
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
let mut dummy = [0u8; 0];
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
}
pub fn eject(&mut self) -> Result<()> {
// PREVENT ALLOW MEDIUM REMOVAL: allow removal
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
let mut buf = [0u8; 0];
let _ = self.scsi.as_mut().execute(&allow_cdb, crate::scsi::DataDirection::None, &mut buf, 5_000);
// START STOP UNIT: LoEj=1, Start=0
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
self.scsi.as_mut().execute(&eject_cdb, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
Ok(())
}
/// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
pub fn scsi_execute(
&mut self,
cdb: &[u8],
direction: crate::scsi::DataDirection,
buf: &mut [u8],
timeout_ms: u32,
&mut self, cdb: &[u8], direction: crate::scsi::DataDirection,
buf: &mut [u8], timeout_ms: u32,
) -> Result<crate::scsi::ScsiResult> {
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
}
}
/// Create the platform-specific driver for a given chipset.
fn create_platform(profile: &DriveProfile, drive_id: &DriveId) -> Result<Box<dyn Platform>> {
match profile.chipset {
Chipset::MediaTek => Ok(Box::new(Mt1959::new(profile.clone()))),
Chipset::Renesas => Err(Error::UnsupportedDrive {
vendor_id: drive_id.vendor_id.trim().to_string(),
product_id: drive_id.product_id.trim().to_string(),
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(),
}),
}
+144 -40
View File
@@ -1,8 +1,7 @@
//! Error types for libfreemkv.
//!
//! Every error carries a numeric code for programmatic handling.
//! No user-facing English text — applications format their own messages.
//! This keeps the library locale-independent and testable.
//! Every error is a code with structured data. No English text.
//! Applications map codes to localized messages.
//!
//! # Error Code Ranges
//!
@@ -15,8 +14,10 @@
//! | E5xxx | I/O errors |
//! | E6xxx | Disc format errors |
//! | E7xxx | AACS errors |
//! | E8xxx | Keydb errors |
//! | E9xxx | Mux errors |
// ── Error codes (single source of truth) ────────────────────────────────────
// ── Error codes ─────────────────────────────────────────────────────────────
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
pub const E_DEVICE_PERMISSION: u16 = 1001;
@@ -30,91 +31,194 @@ pub const E_NOT_CALIBRATED: u16 = 3003;
pub const E_SCSI_ERROR: u16 = 4000;
pub const E_SCSI_TIMEOUT: u16 = 4001;
pub const E_IO_ERROR: u16 = 5000;
pub const E_DISC_ERROR: u16 = 6000;
pub const E_AACS_ERROR: u16 = 7000;
pub const E_WRITE_ERROR: u16 = 5001;
// Disc format (6xxx)
pub const E_DISC_READ: u16 = 6000;
pub const E_MPLS_PARSE: u16 = 6001;
pub const E_CLPI_PARSE: u16 = 6002;
pub const E_UDF_NOT_FOUND: u16 = 6003;
pub const E_DISC_NO_TITLES: u16 = 6004;
pub const E_DISC_TITLE_RANGE: u16 = 6005;
pub const E_DISC_NO_EXTENTS: u16 = 6006;
// AACS (7xxx)
pub const E_AACS_NO_KEYS: u16 = 7000;
pub const E_AACS_CERT_SHORT: u16 = 7001;
pub const E_AACS_AGID_ALLOC: u16 = 7002;
pub const E_AACS_CERT_REJECTED: u16 = 7003;
pub const E_AACS_CERT_READ: u16 = 7004;
pub const E_AACS_CERT_VERIFY: u16 = 7005;
pub const E_AACS_KEY_READ: u16 = 7006;
pub const E_AACS_KEY_REJECTED: u16 = 7007;
pub const E_AACS_KEY_VERIFY: u16 = 7008;
pub const E_AACS_VID_READ: u16 = 7009;
pub const E_AACS_VID_MAC: u16 = 7010;
pub const E_AACS_DATA_KEY: u16 = 7011;
pub const E_AACS_VUK_DERIVE: u16 = 7012;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
pub const E_KEYDB_HTTP: u16 = 8001;
pub const E_KEYDB_INVALID: u16 = 8002;
pub const E_KEYDB_WRITE: u16 = 8003;
pub const E_KEYDB_PARSE: u16 = 8004;
pub const E_KEYDB_LOAD: u16 = 8005;
// Mux (9xxx)
pub const E_MUX_LOOKAHEAD: u16 = 9000;
pub const E_MUX_WRITE: u16 = 9001;
// ── Error enum ──────────────────────────────────────────────────────────────
/// Structured error with numeric code and context data.
/// Structured error with numeric code and context data. No English text.
#[derive(Debug)]
pub enum Error {
// Device
DeviceNotFound { path: String },
DevicePermission { path: String },
// Profile
UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String },
ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String },
ProfileParse { detail: String },
UnlockFailed { detail: String },
ProfileParse,
// Unlock
UnlockFailed,
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
NotUnlocked,
NotCalibrated,
// SCSI
ScsiError { opcode: u8, status: u8, sense_key: u8 },
ScsiTimeout { opcode: u8 },
// I/O
IoError { source: std::io::Error },
DiscError { detail: String },
AacsError { detail: String },
WriteError,
// Disc format
DiscRead { sector: u64 },
MplsParse,
ClpiParse,
UdfNotFound { path: String },
DiscNoTitles,
DiscTitleRange { index: usize, count: usize },
DiscNoExtents,
// AACS
AacsNoKeys,
AacsCertShort,
AacsAgidAlloc,
AacsCertRejected,
AacsCertRead,
AacsCertVerify,
AacsKeyRead,
AacsKeyRejected,
AacsKeyVerify,
AacsVidRead,
AacsVidMac,
AacsDataKey,
AacsVukDerive,
// Keydb
KeydbConnect { host: String },
KeydbHttp { status: u16 },
KeydbInvalid,
KeydbWrite { path: String },
KeydbParse,
KeydbLoad { path: String },
// Mux
MuxLookahead,
MuxWrite,
}
impl Error {
/// Numeric error code.
pub fn code(&self) -> u16 {
match self {
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND,
Error::ProfileParse { .. } => E_PROFILE_PARSE,
Error::UnlockFailed { .. } => E_UNLOCK_FAILED,
Error::ProfileParse => E_PROFILE_PARSE,
Error::UnlockFailed => E_UNLOCK_FAILED,
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
Error::NotUnlocked => E_NOT_UNLOCKED,
Error::NotCalibrated => E_NOT_CALIBRATED,
Error::ScsiError { .. } => E_SCSI_ERROR,
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
Error::IoError { .. } => E_IO_ERROR,
Error::DiscError { .. } => E_DISC_ERROR,
Error::AacsError { .. } => E_AACS_ERROR,
Error::WriteError => E_WRITE_ERROR,
Error::DiscRead { .. } => E_DISC_READ,
Error::MplsParse => E_MPLS_PARSE,
Error::ClpiParse => E_CLPI_PARSE,
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
Error::DiscNoTitles => E_DISC_NO_TITLES,
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
Error::DiscNoExtents => E_DISC_NO_EXTENTS,
Error::AacsNoKeys => E_AACS_NO_KEYS,
Error::AacsCertShort => E_AACS_CERT_SHORT,
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
Error::AacsCertRejected => E_AACS_CERT_REJECTED,
Error::AacsCertRead => E_AACS_CERT_READ,
Error::AacsCertVerify => E_AACS_CERT_VERIFY,
Error::AacsKeyRead => E_AACS_KEY_READ,
Error::AacsKeyRejected => E_AACS_KEY_REJECTED,
Error::AacsKeyVerify => E_AACS_KEY_VERIFY,
Error::AacsVidRead => E_AACS_VID_READ,
Error::AacsVidMac => E_AACS_VID_MAC,
Error::AacsDataKey => E_AACS_DATA_KEY,
Error::AacsVukDerive => E_AACS_VUK_DERIVE,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID,
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
Error::KeydbParse => E_KEYDB_PARSE,
Error::KeydbLoad { .. } => E_KEYDB_LOAD,
Error::MuxLookahead => E_MUX_LOOKAHEAD,
Error::MuxWrite => E_MUX_WRITE,
}
}
}
/// Display format: "E{code}: {context}" — terse, for logs.
/// Applications should format their own user-facing messages using code() and fields.
/// Display: "E{code}" with structured data. No English words.
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::DeviceNotFound { path } =>
write!(f, "E{}: {}", E_DEVICE_NOT_FOUND, path),
write!(f, "E{}: {}", self.code(), path),
Error::DevicePermission { path } =>
write!(f, "E{}: {}", E_DEVICE_PERMISSION, path),
write!(f, "E{}: {}", self.code(), path),
Error::UnsupportedDrive { vendor_id, product_id, product_revision } =>
write!(f, "E{}: {} {} {}", E_UNSUPPORTED_DRIVE,
write!(f, "E{}: {} {} {}", self.code(),
vendor_id.trim(), product_id.trim(), product_revision.trim()),
Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } =>
write!(f, "E{}: {} {} {}", E_PROFILE_NOT_FOUND,
write!(f, "E{}: {} {} {}", self.code(),
vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
Error::ProfileParse { detail } =>
write!(f, "E{}: {}", E_PROFILE_PARSE, detail),
Error::UnlockFailed { detail } =>
write!(f, "E{}: {}", E_UNLOCK_FAILED, detail),
Error::SignatureMismatch { expected, got } =>
write!(f, "E{}: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
E_SIGNATURE_MISMATCH,
write!(f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
self.code(),
expected[0], expected[1], expected[2], expected[3],
got[0], got[1], got[2], got[3]),
Error::NotUnlocked =>
write!(f, "E{}", E_NOT_UNLOCKED),
Error::NotCalibrated =>
write!(f, "E{}", E_NOT_CALIBRATED),
Error::ScsiError { opcode, status, sense_key } =>
write!(f, "E{}: opcode=0x{:02x} status=0x{:02x} sense=0x{:02x}",
E_SCSI_ERROR, opcode, status, sense_key),
write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key),
Error::ScsiTimeout { opcode } =>
write!(f, "E{}: opcode=0x{:02x}", E_SCSI_TIMEOUT, opcode),
write!(f, "E{}: 0x{:02x}", self.code(), opcode),
Error::IoError { source } =>
write!(f, "E{}: {}", E_IO_ERROR, source),
Error::DiscError { detail } =>
write!(f, "E{}: {}", E_DISC_ERROR, detail),
Error::AacsError { detail } =>
write!(f, "E{}: {}", E_AACS_ERROR, detail),
write!(f, "E{}: {}", self.code(), source),
Error::DiscRead { sector } =>
write!(f, "E{}: {}", self.code(), sector),
Error::UdfNotFound { path } =>
write!(f, "E{}: {}", self.code(), path),
Error::DiscTitleRange { index, count } =>
write!(f, "E{}: {}/{}", self.code(), index, count),
Error::KeydbConnect { host } =>
write!(f, "E{}: {}", 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()),
}
}
}
+76
View File
@@ -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) {}
+5
View File
@@ -44,6 +44,9 @@ pub struct DriveId {
/// Raw 96-byte INQUIRY response for additional parsing if needed.
pub raw_inquiry: Vec<u8>,
/// Raw GET CONFIGURATION Feature 010Ch response bytes.
pub raw_gc_010c: Vec<u8>,
}
impl DriveId {
@@ -84,6 +87,7 @@ impl DriveId {
firmware_date,
serial_number,
raw_inquiry: inquiry.to_vec(),
raw_gc_010c: gc[..result.bytes_transferred].to_vec(),
})
}
@@ -98,6 +102,7 @@ impl DriveId {
firmware_date: firmware_date.to_string(),
serial_number: String::new(),
raw_inquiry: inquiry.to_vec(),
raw_gc_010c: Vec::new(),
}
}
-342
View File
@@ -1,342 +0,0 @@
//! BD-J JAR parser — extract audio/subtitle track labels from disc menus.
//!
//! Blu-ray discs with BD-J menus store the menu application as Java JAR files
//! in BDMV/JAR/. These contain .class files with string constants that label
//! audio and subtitle tracks.
//!
//! Multiple label formats exist across studios:
//! - Label format: "eng_MLP_", "fra_AudioStream3" (Warner UHD, e.g. Barbie, Dune)
//! - TextField format: "TextField,Audio1,English Dolby Atmos,..." (A24/Lionsgate, e.g. Civil War)
//!
//! Labels match to streams by STN index — the Nth audio label in the JAR
//! corresponds to the Nth audio stream in the MPLS STN table. This is defined
//! by the BD-J API where stream numbers = STN indices (1-based).
use std::io::Read;
/// Labels extracted from a BD-J JAR file.
#[derive(Debug, Default)]
pub struct JarLabels {
/// Audio track labels in STN order
pub audio: Vec<TrackLabel>,
/// Subtitle track labels in STN order
pub subtitle: Vec<TrackLabel>,
/// Playlist purpose labels (MAIN_FEATURE, FORCED_TRAILER, etc.)
pub playlists: Vec<String>,
}
/// A parsed track label from the JAR.
#[derive(Debug, Clone)]
pub struct TrackLabel {
/// Human-readable description (e.g. "English Dolby Atmos", "TrueHD", "Descriptive Audio (US)")
pub description: String,
/// ISO 639-2 language code if available (e.g. "eng", "fra")
pub language: String,
/// Codec hint if available (e.g. "MLP"=TrueHD, "AC3"=DD, "DTS")
pub codec_hint: String,
/// The raw string from the class file
pub raw: String,
}
/// Extract track labels from a JAR file (raw ZIP bytes).
///
/// Tries multiple format parsers. Returns None if no labels found.
pub fn extract_labels(jar_data: &[u8]) -> Option<JarLabels> {
let strings = extract_all_jar_strings(jar_data)?;
// Try each format parser in order
try_textfield_format(&strings)
.or_else(|| try_label_format(&strings))
.or_else(|| try_playlist_only(&strings))
}
// ── Format 1: TextField (A24/Lionsgate) ─────────────────────────────────────
//
// Pattern: "TextField,Audio{N},{description},..."
// "TextField,Subtitle{N},{description},..."
// Found in: Civil War, and similar A24/Lionsgate discs
fn try_textfield_format(strings: &[String]) -> Option<JarLabels> {
let mut audio: Vec<(u32, String, String)> = Vec::new(); // (index, description, raw)
let mut subtitle: Vec<(u32, String, String)> = Vec::new();
let mut playlists = Vec::new();
for s in strings {
if s.starts_with("TextField,Audio") {
// TextField,Audio{N},{description},...
let parts: Vec<&str> = s.splitn(4, ',').collect();
if parts.len() >= 3 {
let key = parts[1]; // "Audio1", "Audio2", etc.
let desc = parts[2].to_string();
if let Some(num_str) = key.strip_prefix("Audio") {
if let Ok(idx) = num_str.parse::<u32>() {
if !desc.is_empty() {
audio.push((idx, desc, s.clone()));
}
}
}
}
} else if s.starts_with("TextField,Subtitle") {
let parts: Vec<&str> = s.splitn(4, ',').collect();
if parts.len() >= 3 {
let key = parts[1];
let desc = parts[2].to_string();
if let Some(num_str) = key.strip_prefix("Subtitle") {
if let Ok(idx) = num_str.parse::<u32>() {
if !desc.eq_ignore_ascii_case("None") && !desc.is_empty() {
subtitle.push((idx, desc, s.clone()));
}
}
}
}
}
collect_playlist_label(s, &mut playlists);
}
if audio.is_empty() && subtitle.is_empty() {
return None;
}
// Sort by index to ensure STN order
audio.sort_by_key(|a| a.0);
subtitle.sort_by_key(|s| s.0);
Some(JarLabels {
audio: audio.into_iter().map(|(_, desc, raw)| TrackLabel {
description: desc, language: String::new(), codec_hint: String::new(), raw,
}).collect(),
subtitle: subtitle.into_iter().map(|(_, desc, raw)| TrackLabel {
description: desc, language: String::new(), codec_hint: String::new(), raw,
}).collect(),
playlists,
})
}
// ── Format 2: Label strings (Warner UHD) ────────────────────────────────────
//
// Pattern: "eng_MLP_", "fra_AudioStream3", "dan_PGStream4"
// Found in: Barbie, Dune Part Two, and similar Warner UHD discs
fn try_label_format(strings: &[String]) -> Option<JarLabels> {
let mut audio = Vec::new();
let mut subtitle = Vec::new();
let mut playlists = Vec::new();
for s in strings {
if let Some(label) = parse_label_string(s) {
if label.is_audio && !audio.iter().any(|a: &TrackLabel| a.raw == label.raw) {
audio.push(TrackLabel {
description: label.description, language: label.language,
codec_hint: label.codec_hint, raw: label.raw,
});
} else if label.is_subtitle && !subtitle.iter().any(|a: &TrackLabel| a.raw == label.raw) {
subtitle.push(TrackLabel {
description: label.description, language: label.language,
codec_hint: label.codec_hint, raw: label.raw,
});
}
}
collect_playlist_label(s, &mut playlists);
}
if audio.is_empty() && subtitle.is_empty() {
return None;
}
Some(JarLabels { audio, subtitle, playlists })
}
struct ParsedLabel {
description: String,
language: String,
codec_hint: String,
raw: String,
is_audio: bool,
is_subtitle: bool,
}
fn parse_label_string(s: &str) -> Option<ParsedLabel> {
let clean = s.trim_end_matches('_');
let parts: Vec<&str> = clean.splitn(3, '_').collect();
if parts.len() < 2 { return None; }
let language = parts[0];
if language.len() < 2 || language.len() > 3 || !language.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
let hint = parts[1];
let variant = if parts.len() > 2 { parts[2] } else { "" };
// Only set description when the JAR adds info beyond what MPLS provides.
// Codec (MLP, AC3, DTS) is already known from the stream. Don't make up labels.
let (description, codec_hint, is_audio, is_subtitle) = match hint {
"MLP" => (String::new(), "MLP".to_string(), true, false),
"AC3" => (String::new(), "AC3".to_string(), true, false),
"DTS" => (String::new(), "DTS".to_string(), true, false),
"LPCM" => (String::new(), "LPCM".to_string(), true, false),
"ADES" => {
// Descriptive Audio — real value-add, can't be derived from MPLS
let d = if variant.is_empty() { "Descriptive Audio".to_string() }
else { format!("Descriptive Audio ({})", variant) };
(d, "ADES".to_string(), true, false)
}
h if h.starts_with("AudioStream") => (String::new(), String::new(), true, false),
h if h.starts_with("PGStream") => (String::new(), String::new(), false, true),
_ => return None,
};
Some(ParsedLabel {
description, language: language.to_string(), codec_hint,
raw: s.to_string(), is_audio, is_subtitle,
})
}
// ── Format 3: Playlist-only ─────────────────────────────────────────────────
//
// Some JARs have no track labels but do have playlist purpose markers.
fn try_playlist_only(strings: &[String]) -> Option<JarLabels> {
let mut playlists = Vec::new();
for s in strings {
collect_playlist_label(s, &mut playlists);
}
if playlists.is_empty() { return None; }
Some(JarLabels { audio: Vec::new(), subtitle: Vec::new(), playlists })
}
// ── Common helpers ──────────────────────────────────────────────────────────
fn collect_playlist_label(s: &str, playlists: &mut Vec<String>) {
if matches!(s.as_ref(),
"MAIN_FEATURE" | "MAIN_FEATURE_INTRO" |
"FORCED_TRAILER" | "INTL_FORCED_TRAILER" |
"commentary_extras" | "extras"
) {
if !playlists.iter().any(|p| p == s) {
playlists.push(s.to_string());
}
}
}
/// Extract all UTF-8 string constants from all .class files in a JAR.
fn extract_all_jar_strings(jar_data: &[u8]) -> Option<Vec<String>> {
let cursor = std::io::Cursor::new(jar_data);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut all_strings = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i).ok()?;
if !file.name().ends_with(".class") { continue; }
let mut data = Vec::new();
file.read_to_end(&mut data).ok()?;
extract_class_strings(&data, &mut all_strings);
}
if all_strings.is_empty() { return None; }
Some(all_strings)
}
/// Extract UTF-8 string constants from a Java .class file's constant pool.
fn extract_class_strings(data: &[u8], out: &mut Vec<String>) {
if data.len() < 10 || &data[0..4] != &[0xCA, 0xFE, 0xBA, 0xBE] {
return;
}
let cp_count = ((data[8] as u16) << 8 | data[9] as u16) as usize;
let mut pos = 10;
let mut entry = 1;
while entry < cp_count && pos < data.len() {
let tag = data[pos];
pos += 1;
match tag {
// CONSTANT_Utf8
1 => {
if pos + 2 > data.len() { break; }
let len = ((data[pos] as usize) << 8) | data[pos + 1] as usize;
pos += 2;
if pos + len > data.len() { break; }
if let Ok(s) = std::str::from_utf8(&data[pos..pos + len]) {
if s.len() >= 3 && s.len() <= 500 {
out.push(s.to_string());
}
}
pos += len;
}
3 | 4 => { pos += 4; }
5 | 6 => { pos += 8; entry += 1; }
7 | 8 | 16 => { pos += 2; }
9 | 10 | 11 | 12 | 18 => { pos += 4; }
15 => { pos += 3; }
_ => { break; }
}
entry += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_textfield_audio() {
let strings = vec![
"TextField,Audio1,English Dolby Atmos,Font,296,763,275,25,left".to_string(),
"TextField,Audio2,English Descriptive Audio,Font,296,803,275,25,left".to_string(),
"TextField,Audio3,Spanish 5.1 Dolby Digital,Font,296,843,275,25,left".to_string(),
];
let labels = try_textfield_format(&strings).unwrap();
assert_eq!(labels.audio.len(), 3);
assert_eq!(labels.audio[0].description, "English Dolby Atmos");
assert_eq!(labels.audio[1].description, "English Descriptive Audio");
assert_eq!(labels.audio[2].description, "Spanish 5.1 Dolby Digital");
}
#[test]
fn test_textfield_subtitle_skips_none() {
let strings = vec![
"TextField,Subtitle0,None,Font,1312,843,275,25,left".to_string(),
"TextField,Subtitle1,English SDH,Font,1312,763,275,25,left".to_string(),
"TextField,Subtitle2,Spanish,Font,1312,803,275,25,left".to_string(),
];
let labels = try_textfield_format(&strings).unwrap();
assert_eq!(labels.subtitle.len(), 2);
assert_eq!(labels.subtitle[0].description, "English SDH");
assert_eq!(labels.subtitle[1].description, "Spanish");
}
#[test]
fn test_label_format_audio() {
let strings = vec![
"eng_MLP_".to_string(),
"eng_ADES_US_".to_string(),
"fra_AudioStream3".to_string(),
];
let labels = try_label_format(&strings).unwrap();
assert_eq!(labels.audio.len(), 3);
assert_eq!(labels.audio[0].description, ""); // MLP — codec already known from stream
assert_eq!(labels.audio[1].description, "Descriptive Audio (US)"); // ADES — real value-add
}
#[test]
fn test_label_format_subtitle() {
let strings = vec!["dan_PGStream4".to_string()];
let labels = try_label_format(&strings).unwrap();
assert_eq!(labels.subtitle.len(), 1);
}
#[test]
fn test_no_labels() {
let strings = vec!["java/lang/Object".to_string(), "toString".to_string()];
assert!(try_textfield_format(&strings).is_none());
assert!(try_label_format(&strings).is_none());
}
}
+161
View File
@@ -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)
}
+27 -21
View File
@@ -1,4 +1,4 @@
//! libfreemkv — Open source optical drive library for 4K UHD / Blu-ray / DVD.
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
//!
//! Handles drive access, disc structure parsing, AACS decryption, and raw
//! sector reading. 206 bundled drive profiles. No external files needed.
@@ -6,14 +6,15 @@
//! # Quick Start
//!
//! ```no_run
//! use libfreemkv::{DriveSession, Disc, ScanOptions};
//! 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();
//!
//! for title in &disc.titles {
//! println!("{} — {} streams", title.duration_display(), title.streams.len());
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
//! }
//!
//! // Read content (decrypted automatically if AACS keys available)
@@ -26,21 +27,21 @@
//! # Architecture
//!
//! ```text
//! DriveSession — open, identify, unlock, read sectors
//! ├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
//! ├── DriveProfile — per-drive unlock parameters (206 bundled)
//! ├── DriveId — INQUIRY + GET_CONFIG identification
//! DriveSession -- open, identify, unlock, read sectors
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
//! ├── DriveProfile -- per-drive unlock parameters (206 bundled)
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
//! └── Platform
//! └── Mt1959 — MediaTek unlock/read (Renesas planned)
//! └── Mt1959 -- MediaTek unlock/read (Renesas planned)
//!
//! Disc — scan titles, streams, AACS state
//! ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
//! ├── MPLS parser — playlists → titles + clips + STN streams
//! ├── CLPI parser — clip info → EP map → sector extents
//! ├── JAR parser — BD-J audio track labels
//! └── AACS — encryption: key resolution + content decrypt
//! ├── aacs — KEYDB, VUK, MKB, unit decrypt
//! └── handshake — SCSI auth, ECDH, bus key
//! Disc -- scan titles, streams, AACS state
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
//! ├── MPLS parser -- playlists → titles + clips + STN streams
//! ├── CLPI parser -- clip info → EP map → sector extents
//! ├── JAR parser -- BD-J audio track labels
//! └── AACS -- encryption: key resolution + content decrypt
//! ├── aacs -- KEYDB, VUK, MKB, unit decrypt
//! └── handshake -- SCSI auth, ECDH, bus key
//! ```
//!
//! # AACS Encryption
@@ -54,7 +55,7 @@
//! # Error Codes
//!
//! All errors are structured with numeric codes. No user-facing English
//! text — applications format their own messages.
//! text -- applications format their own messages.
//!
//! | Range | Category |
//! |-------|----------|
@@ -79,14 +80,19 @@ 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;
+49 -8
View File
@@ -62,17 +62,17 @@ pub struct StreamEntry {
/// Parse an MPLS file from raw bytes.
pub fn parse(data: &[u8]) -> Result<Playlist> {
if data.len() < 40 {
return Err(Error::DiscError { detail: "MPLS too short".into() });
return Err(Error::MplsParse);
}
if &data[0..4] != b"MPLS" {
return Err(Error::DiscError { detail: "not an MPLS file".into() });
return Err(Error::MplsParse);
}
let version = String::from_utf8_lossy(&data[4..8]).to_string();
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
if playlist_start + 10 > data.len() {
return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() });
return Err(Error::MplsParse);
}
let pl = &data[playlist_start..];
@@ -111,7 +111,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
let n_ig = item[STN_OFFSET + 7] as usize;
let n_sec_audio = item[STN_OFFSET + 8] as usize;
let n_sec_video = item[STN_OFFSET + 9] as usize;
let _n_pip_pg = item[STN_OFFSET + 10] as usize;
let n_pip_pg = item[STN_OFFSET + 10] as usize;
let n_dv = item[STN_OFFSET + 11] as usize;
let mut spos = STN_OFFSET + 16;
@@ -173,6 +173,18 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
} else { spos = next; }
} else { break; }
}
// Secondary PG (PiP subtitles) — must consume to keep spos aligned
for _ in 0..n_pip_pg {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 3) {
entry.secondary = true;
streams.push(entry);
// Skip reference data: num_refs(1) + reserved(1) + refs + padding
if next < item.len() {
let n_refs = item[next] as usize;
spos = next + 2 + n_refs + (n_refs % 2);
} else { spos = next; }
} else { break; }
}
// Dolby Vision enhancement layer
for _ in 0..n_dv {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
@@ -227,6 +239,7 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
let sa = &item[se_end + 1..se_end + 1 + sa_len];
let coding_type = sa[0];
let mut video_format = 0u8;
let mut video_rate = 0u8;
let mut audio_format = 0u8;
@@ -249,6 +262,29 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
}
2 => {
// Audio: coding_type(1) + format_rate(1) + language(3)
// Exception: PGS (0x90/0x91) in audio slot uses PG layout: coding_type(1) + language(3)
if coding_type == 0x90 || coding_type == 0x91 {
if sa.len() >= 4 {
language = String::from_utf8_lossy(&sa[1..4]).to_string();
}
} else {
if sa.len() >= 2 {
audio_format = (sa[1] >> 4) & 0x0F;
audio_rate = sa[1] & 0x0F;
}
if sa.len() >= 5 {
language = String::from_utf8_lossy(&sa[2..5]).to_string();
}
}
}
3 | 4 => {
// PG/IG: coding_type(1) + language(3)
if sa.len() >= 4 {
language = String::from_utf8_lossy(&sa[1..4]).to_string();
}
}
5 => {
// Secondary audio: same as primary audio
if sa.len() >= 2 {
audio_format = (sa[1] >> 4) & 0x0F;
audio_rate = sa[1] & 0x0F;
@@ -257,10 +293,15 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
language = String::from_utf8_lossy(&sa[2..5]).to_string();
}
}
3 | 4 => {
// PG/IG: coding_type(1) + language(3)
if sa.len() >= 4 {
language = String::from_utf8_lossy(&sa[1..4]).to_string();
6 | 7 => {
// Secondary video: same as primary video
if sa.len() >= 2 {
video_format = (sa[1] >> 4) & 0x0F;
video_rate = sa[1] & 0x0F;
}
if coding_type == 0x24 && sa.len() > 2 {
dynamic_range = (sa[2] >> 4) & 0x0F;
color_space_val = sa[2] & 0x0F;
}
}
_ => {}
+37
View File
@@ -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
}
}
+26
View File
@@ -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 }
}
+190
View File
@@ -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
}
+170
View File
@@ -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)
}
}
+88
View File
@@ -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)),
}
}
+25
View File
@@ -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 }
}
+27
View File
@@ -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 }
}
+115
View File
@@ -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
View File
@@ -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]);
}
}
+127
View File
@@ -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
View File
@@ -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 }
}
+25
View File
@@ -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;
+218
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
-329
View File
@@ -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
}
}
+234
View File
@@ -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
}
}
+38
View File
@@ -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(())
}
+56
View File
@@ -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
View File
@@ -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]
+5 -93
View File
@@ -1,41 +1,14 @@
//! Drive speed control — query and set read speeds.
//!
//! Uses MMC-6 SET CD SPEED (0xBB) command.
//! Reference: MMC-6 §6.30
//! Drive speed constants.
/// Disc read speed.
/// Common optical drive speeds with KB/s values for SET_CD_SPEED.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DriveSpeed {
/// Blu-ray 1x = 4,500 KB/s
BD1x,
/// Blu-ray 2x = 9,000 KB/s
BD2x,
/// Blu-ray 4x = 18,000 KB/s
BD4x,
/// Blu-ray 6x = 27,000 KB/s
BD6x,
/// Blu-ray 8x = 36,000 KB/s
BD8x,
/// Blu-ray 10x = 45,000 KB/s
BD10x,
/// Blu-ray 12x = 54,000 KB/s
BD12x,
/// DVD 1x = 1,385 KB/s
DVD1x,
/// DVD 2x = 2,770 KB/s
DVD2x,
/// DVD 4x = 5,540 KB/s
DVD4x,
/// DVD 8x = 11,080 KB/s
DVD8x,
/// DVD 16x = 22,160 KB/s
DVD16x,
/// Maximum speed — drive decides
BD1x, BD2x, BD4x, BD6x, BD8x, BD10x, BD12x,
DVD1x, DVD2x, DVD4x, DVD8x, DVD16x,
Max,
}
impl DriveSpeed {
/// Convert to KB/s for MMC-6 SET CD SPEED command.
pub fn to_kbps(self) -> u16 {
match self {
DriveSpeed::BD1x => 4_500,
@@ -53,71 +26,10 @@ impl DriveSpeed {
DriveSpeed::Max => 0xFFFF,
}
}
/// Create from KB/s value, rounding to nearest standard speed.
pub fn from_kbps(kbps: u16) -> Self {
match kbps {
0..=2_000 => DriveSpeed::DVD1x,
2_001..=4_000 => DriveSpeed::DVD2x,
4_001..=6_000 => DriveSpeed::BD1x,
6_001..=13_000 => DriveSpeed::BD2x,
13_001..=22_000 => DriveSpeed::BD4x,
22_001..=31_000 => DriveSpeed::BD6x,
31_001..=40_000 => DriveSpeed::BD8x,
40_001..=49_000 => DriveSpeed::BD10x,
49_001..=u16::MAX => DriveSpeed::BD12x,
}
}
/// Human-readable label.
pub fn label(&self) -> &'static str {
match self {
DriveSpeed::BD1x => "BD 1x",
DriveSpeed::BD2x => "BD 2x",
DriveSpeed::BD4x => "BD 4x",
DriveSpeed::BD6x => "BD 6x",
DriveSpeed::BD8x => "BD 8x",
DriveSpeed::BD10x => "BD 10x",
DriveSpeed::BD12x => "BD 12x",
DriveSpeed::DVD1x => "DVD 1x",
DriveSpeed::DVD2x => "DVD 2x",
DriveSpeed::DVD4x => "DVD 4x",
DriveSpeed::DVD8x => "DVD 8x",
DriveSpeed::DVD16x => "DVD 16x",
DriveSpeed::Max => "Max",
}
}
/// All standard Blu-ray speeds.
pub fn all_bd() -> &'static [DriveSpeed] {
&[DriveSpeed::BD1x, DriveSpeed::BD2x, DriveSpeed::BD4x,
DriveSpeed::BD6x, DriveSpeed::BD8x, DriveSpeed::BD10x, DriveSpeed::BD12x]
}
/// All standard DVD speeds.
pub fn all_dvd() -> &'static [DriveSpeed] {
&[DriveSpeed::DVD1x, DriveSpeed::DVD2x, DriveSpeed::DVD4x,
DriveSpeed::DVD8x, DriveSpeed::DVD16x]
}
}
impl std::fmt::Display for DriveSpeed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({} KB/s)", self.label(), self.to_kbps())
write!(f, "{:?} ({} KB/s)", self, self.to_kbps())
}
}
/// Build SET CD SPEED CDB — MMC-6 §6.30
pub fn set_cd_speed_cdb(read_speed: DriveSpeed) -> [u8; 12] {
let kbps = read_speed.to_kbps();
[
0xBB, // SET CD SPEED opcode
0x00, // reserved
(kbps >> 8) as u8, // read speed MSB
kbps as u8, // read speed LSB
0xFF, // write speed MSB (0xFFFF = don't change)
0xFF, // write speed LSB
0x00, 0x00, 0x00, 0x00, // reserved
0x00, 0x00, // reserved
]
}
+101 -37
View File
@@ -72,6 +72,29 @@ impl UdfFs {
/// 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;
@@ -80,18 +103,19 @@ impl UdfFs {
for part in &parts[..parts.len() - 1] {
current = current.entries.iter().find(|e| {
e.is_dir && e.name.eq_ignore_ascii_case(part)
}).ok_or_else(|| Error::DiscError {
detail: format!("directory not found: {}", part),
})?;
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
)?;
}
// Find the file
let filename = parts.last().unwrap();
let filename = match parts.last() {
Some(f) => f,
None => return Err(Error::UdfNotFound { path: path.to_string() }),
};
let entry = current.entries.iter().find(|e| {
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
}).ok_or_else(|| Error::DiscError {
detail: format!("file not found: {}", path),
})?;
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
)?;
// Read the file's ICB to get its data extent
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
@@ -172,45 +196,91 @@ impl UdfFs {
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
/// and return its first allocation extent: (data_lba, data_length).
/// The data_lba is metadata-relative.
/// 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 based on ICB type
let ad_offset = match tag {
// 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)
// Layout: ... L_EA at [208:212], L_AD at [212:216], alloc descs at 216 + L_EA
266 => {
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
216 + l_ea
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
// Layout: ... L_EA at [168:172], L_AD at [172:176], alloc descs at 176 + L_EA
261 => {
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
176 + l_ea
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
(176 + l_ea, l_ad)
}
_ => return Err(Error::DiscError {
detail: format!("unexpected ICB tag {} at meta_lba {}", tag, meta_lba),
}),
_ => return Err(Error::DiscRead { sector: 0 }),
};
if ad_offset + 8 > 2048 {
return Err(Error::DiscError { detail: "ICB alloc desc out of range".into() });
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,
}
}
// Short Allocation Descriptor: extent_length(4) + extent_position(4)
// extent_length upper 2 bits = type (0=recorded, 1=allocated not recorded, 3=next extent)
let raw_len = u32::from_le_bytes([icb[ad_offset], icb[ad_offset + 1],
icb[ad_offset + 2], icb[ad_offset + 3]]);
let data_len = raw_len & 0x3FFFFFFF;
let data_lba = u32::from_le_bytes([icb[ad_offset + 4], icb[ad_offset + 5],
icb[ad_offset + 6], icb[ad_offset + 7]]);
Ok(extents)
}
Ok((data_lba, data_len))
/// 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)
}
}
@@ -231,9 +301,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
if tag_id != 2 {
return Err(Error::DiscError {
detail: format!("AVDP: expected tag 2, got {} at sector 256", tag_id),
});
return Err(Error::DiscRead { sector: 0 });
}
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
@@ -273,16 +341,14 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
}
if partition_start == 0 {
return Err(Error::DiscError { detail: "UDF: no Partition Descriptor found".into() });
return Err(Error::DiscRead { sector: 0 });
}
// Step 3: Parse partition maps from LVD to find metadata partition
// BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition")
// The metadata file is stored at lba=0 of the physical partition
let metadata_start = if num_partition_maps >= 2 {
let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscError {
detail: "UDF: no LVD found".into(),
})?;
let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscRead { sector: 0 })?;
// Read LVD to check partition map type
let mut lvd = [0u8; 2048];
@@ -339,9 +405,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
if fsd_tag != 256 {
return Err(Error::DiscError {
detail: format!("FSD: expected tag 256, got {} at sector {}", fsd_tag, metadata_start),
});
return Err(Error::DiscRead { sector: 0 });
}
// Root Directory ICB: long_ad at FSD offset 400