Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Local only — needs more disc samples to validate before publishing.
2026-04-06 16:09:30 -07:00
MattJackson b34b7dd2e6 Improve API docs, add docs.rs badge + link 2026-04-06 15:58:32 -07:00
41 changed files with 12455 additions and 2704 deletions
+36
View File
@@ -0,0 +1,36 @@
# Changelog
## 0.4.0 (2026-04-07)
### Labels — complete rewrite
- **Detect-then-parse architecture**: each BD-J authoring format has its own parser module with `detect()` and `parse()` functions. Drop in a new parser with one line in the registry.
- **5 format parsers**: Paramount (`playlists.xml`), Criterion (`streamproperties.xml`), Pixelogic (`bluray_project.bin`), Warner CTRM (`menu_base.prop` / `language_streams.txt`), shared label vocabulary (`vocab.rs`)
- **Raw disc data principle**: label data passes through as-is from disc. Only BD-standard codec identifiers (MLP, AC3, DTS) are mapped to display names. Unknown authoring tool codes (csp, eda, cf) pass through raw.
- **`variant` field**: replaces `region` — language dialect codes from authoring tools, not BD spec regions
- Removed: old `jar` module (superseded by labels)
- Removed: dead label apply functions from disc.rs
### Drive
- **`DriveSession::eject()`**: sends PREVENT ALLOW MEDIUM REMOVAL then START STOP UNIT. Works reliably after raw mode unlock.
- **`DiscRegion` enum**: Free, BluRay(A/B/C), Dvd(1-8). UHD always region-free.
### Capture
- **Fixed sector range collection**: captures ALL files on disc (only skips STREAM/ video files and >50MB). Previously skipped BACKUP/, DUPLICATE/, and files >10MB which missed JAR content.
## 0.3.1
- Labels module: 4 disc file parsers for stream labels
- Simplified labels API
## 0.3.0
- Initial public release
- SCSI transport (Linux SG_IO, macOS IOKit)
- UDF 2.50 filesystem reader
- MPLS/CLPI parsers with full STN support
- Drive identification + profile matching
- 206 bundled drive profiles
- AACS 1.0 decryption (VUK + unit keys)
+11 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.3.0"
version = "0.5.0"
edition = "2021"
license = "AGPL-3.0-only"
description = "Open source raw disc access library for optical drives"
@@ -12,17 +12,21 @@ categories = ["hardware-support", "multimedia"]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10"
sha2 = "0.10"
aes = "0.8"
cbc = "0.1"
flate2 = "1"
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
rand = "0.8"
cmac = "0.7"
zip = { version = "2", default-features = false, features = ["deflate"] }
base64 = "0.22.1"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
[[bin]]
name = "freemkv-info"
path = "src/bin/freemkv_info.rs"
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
[[bin]]
name = "freemkv-test"
path = "src/bin/freemkv_test.rs"
+63 -84
View File
@@ -1,10 +1,14 @@
[![Crates.io](https://img.shields.io/crates/v/libfreemkv)](https://crates.io/crates/libfreemkv)
[![docs.rs](https://img.shields.io/docsrs/libfreemkv)](https://docs.rs/libfreemkv)
[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
[![Drives: 206](https://img.shields.io/badge/drives-206-brightgreen)]()
# libfreemkv
Rust library for raw sector access on optical drives. Identifies drives using standard SCSI commands, matches them against 206 bundled profiles, and unlocks raw read mode. No external files, no configuration.
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, stream labels, AACS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
Part of the [freemkv](https://github.com/freemkv) project.
@@ -12,113 +16,88 @@ Part of the [freemkv](https://github.com/freemkv) project.
```toml
[dependencies]
libfreemkv = "0.2"
libfreemkv = "0.4"
```
## Quick Start
```rust
use libfreemkv::DriveSession;
use libfreemkv::{DriveSession, Disc, ScanOptions};
use std::path::Path;
// Open drive — profiles are bundled, auto-identified
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
session.unlock()?; // activate raw read mode
session.calibrate()?; // optimize read speed
// Scan disc — UDF, playlists, streams, AACS (all automatic)
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
let mut buf = vec![0u8; 2048];
let n = session.read_sectors(0, 1, &mut buf)?;
```
for title in &disc.titles {
println!("{} — {} streams", title.duration_display(), title.streams.len());
}
## How It Works
1. **INQUIRY** (SPC-4 §6.4) — reads vendor_id, product_id, product_revision, vendor_specific
2. **GET CONFIGURATION 010C** (MMC-6 §5.3.10) — reads firmware_date
3. **Profile match** — five fields uniquely identify the drive against 206 bundled profiles
4. **READ BUFFER** — single command with per-drive mode/buffer_id activates raw read mode
5. **Signature verify** — drive responds with 4-byte signature + "MMkv" confirmation
No fingerprints. No encrypted lookups. All identification uses standard SCSI fields.
## API
```rust
let mut session = DriveSession::open(device_path)?;
// Drive identity
session.drive_id.vendor_id // "HL-DT-ST"
session.drive_id.product_id // "BD-RE BU40N"
session.drive_id.product_revision // "1.03"
session.drive_id.vendor_specific // "NM00000"
session.drive_id.firmware_date // "211810241934"
// Profile data
session.profile.chipset // Chipset::MediaTek
session.profile.unlock_mode // 0x01
session.profile.unlock_buf_id // 0x44
session.profile.signature // [0x99, 0x9e, 0xc3, 0x75]
// Operations
session.unlock()?;
session.calibrate()?;
session.read_sectors(lba, count, &mut buf)?;
session.status()?;
session.read_config()?;
session.read_register(index)?;
```
## Chipset Support
| Chipset | Status | Drives | Brands |
|---------|--------|--------|--------|
| MediaTek MT1959 | Supported | 206 | LG, ASUS, HP |
| Renesas | Planned | -- | Pioneer |
## Drive Profile
Each bundled profile (compiled into the binary):
```json
{
"vendor_id": "HL-DT-ST",
"product_id": "BD-RE BU40N ",
"product_revision": "1.03",
"vendor_specific": "NM00000",
"firmware_date": "211810241934",
"chipset": "mediatek",
"unlock_mode": 1,
"unlock_buf_id": 68,
"signature": "999ec375",
"register_offsets": ["10e291", "11ab1c"]
// Read content (decrypted transparently if AACS keys available)
let mut reader = disc.open_title(&mut session, 0)?;
while let Some(unit) = reader.read_unit()? {
// 6144 bytes of content per aligned unit
}
```
## What It Does
- **Drive access** — open, identify, unlock, eject
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
- **AACS decryption** — transparent key resolution and content decrypt (1.0, 2.0 in progress)
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
- **Content reading** — sector reads with automatic decryption
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
## Architecture
```text
DriveSession — open, identify, unlock, read sectors
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
├── DriveProfile — per-drive unlock parameters (bundled)
└── Platform — MediaTek (supported), Renesas (planned)
Disc — scan titles, streams, AACS state
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
├── MPLS parser — playlists → titles + clips + streams
├── CLPI parser — clip info → EP map → sector extents
├── 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.
## Error Codes
| Code | Error | Meaning |
|------|-------|---------|
| E1000 | DeviceNotFound | Device path doesn't exist |
| E1001 | DevicePermission | No access (try sudo or cdrom group) |
| E2000 | UnsupportedDrive | No matching profile |
| E3000 | UnlockFailed | Unlock command rejected |
| E3001 | SignatureMismatch | Response signature wrong |
| E3002 | NotUnlocked | Operation requires unlock first |
| E4000 | ScsiError | SCSI command failed |
| E5000 | IoError | System I/O error |
All errors are structured with numeric codes. No user-facing English text — applications format their own messages.
## Platform
| Range | Category |
|-------|----------|
| E1xxx | Device errors (not found, permission) |
| E2xxx | Profile errors (unsupported drive) |
| E3xxx | Unlock errors (failed, signature) |
| E4xxx | SCSI errors (command failed, timeout) |
| E5xxx | I/O errors |
| E6xxx | Disc format errors |
| E7xxx | AACS errors |
| E8xxx | KEYDB update errors |
## Platform Support
| Platform | Status | Backend |
|----------|--------|---------|
| Linux | Supported | SG_IO ioctl |
| macOS | Planned | IOKit |
| macOS | Supported | IOKit SCSITask |
| Windows | Planned | SPTI |
The `ScsiTransport` trait abstracts the platform. Adding a backend is one file behind a `cfg` gate.
## Contributing
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to submit your drive's profile.
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
## License
+7
View File
@@ -0,0 +1,7 @@
fn main() {
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-link-lib=framework=IOKit");
println!("cargo:rustc-link-lib=framework=CoreFoundation");
}
}
+32
View File
@@ -0,0 +1,32 @@
# libfreemkv Documentation
Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv), the open source optical drive library.
## Start Here
**[Disc to Rip: End-to-End Flow](disc-to-rip.md)** — How the library goes from a disc in the drive to decrypted content. Read this first.
## Reference
| Document | What it covers |
|----------|---------------|
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
| [Drive Access](drive-access.md) | DriveSession, SCSI transport, profiles, unlock, why raw mode is needed |
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation |
## Reading Order
If you want to understand the whole library:
1. **[Disc to Rip](disc-to-rip.md)** — the big picture
2. **[Architecture](architecture.md)** — how modules fit together
3. **[Drive Access](drive-access.md)** — how we talk to hardware
4. **[UDF](udf.md)** → **[MPLS](mpls.md)** → **[CLPI](clpi.md)** — how disc content is structured
5. **[AACS](aacs.md)** — how encryption works and how we break it
## API Documentation
Generated API docs are on [docs.rs/libfreemkv](https://docs.rs/libfreemkv).
+320
View File
@@ -0,0 +1,320 @@
# AACS Encryption Support
## Overview
AACS (Advanced Access Content System) is the encryption layer used by Blu-ray and UHD 4K discs to protect content. libfreemkv implements AACS decryption to enable transparent disc access.
There are two major versions:
- **AACS 1.0** -- Used by standard Blu-ray discs. Relies on a custom 160-bit elliptic curve for bus authentication and AES-128 for content encryption. Processing keys and device keys can derive the media key from the disc's Media Key Block (MKB).
- **AACS 2.0** -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus encryption layer (read_data_key) on top of the standard content encryption. Uses P-256/SHA-256 for its native handshake, though drives accept AACS 1.0 host certificates for backward compatibility.
Both versions use AES-128-CBC for content decryption with a fixed initialization vector. The fundamental key hierarchy is the same: a Volume Unique Key (VUK) decrypts per-title unit keys, which in turn decrypt the content stream.
## Architecture
AACS support is split across two modules:
### `aacs.rs` -- Keys and Decryption
Handles everything related to key resolution and content decryption:
- KEYDB.cfg parsing (device keys, processing keys, host certificates, per-disc entries)
- Disc hash computation (SHA-1 of `Unit_Key_RO.inf`)
- VUK resolution chain (4 paths, described below)
- MKB record parsing and media key derivation
- Subset-difference tree traversal (AACS-G3 key derivation)
- Unit_Key_RO.inf parsing and unit key decryption
- Content Certificate parsing (AACS version detection)
- Aligned unit decryption (AES-128-CBC)
- Bus decryption (AACS 2.0 read_data_key layer)
### `aacs_handshake.rs` -- SCSI Authentication
Handles the drive-level SCSI authentication protocol:
- ECDH key agreement on the AACS 160-bit curve
- ECDSA signing and verification
- Bus key derivation
- AGID management (allocate/invalidate)
- Volume ID retrieval (encrypted with bus key, verified by AES-CMAC)
- Read Data Key retrieval (for AACS 2.0 bus decryption)
- AACS LA public key certificate verification
## Key Resolution Chain
When a disc is scanned, `resolve_keys()` attempts four paths in priority order. The first path that succeeds is used.
### Path 1: KEYDB VUK Lookup (fastest)
```
Unit_Key_RO.inf --> SHA-1 --> disc_hash --> KEYDB lookup --> VUK
```
The disc hash is computed as the SHA-1 digest of the raw `Unit_Key_RO.inf` file from the disc's `/AACS/` directory. This hash is used as the lookup key in `KEYDB.cfg`. If a matching entry contains a VUK (`V` field), it is used directly.
This is the fast path and resolves the vast majority of discs in a well-maintained KEYDB.
### Path 2: KEYDB Media Key + Volume ID
```
KEYDB media_key + Volume ID (from SCSI handshake) --> VUK derivation
```
If the disc hash is not in the KEYDB but a KEYDB entry has a matching Volume ID (`I` field) and a media key (`M` field), the VUK is derived:
```
VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
```
Requires a successful SCSI handshake to obtain the Volume ID.
### Path 3: MKB + Processing Keys
```
MKB (from disc) + processing_keys (from KEYDB) --> media_key --> VUK
```
Processing keys are pre-computed keys that work against specific MKB versions. For each processing key, the library:
1. Parses the MKB to extract the Verify Media Key Record (`mk_dv`), subset-difference index, and conditional values (cvalues).
2. Tries each processing key against each UV/cvalue pair: `mk = AES-DEC(pk, cvalue) XOR cvalue`.
3. Validates the derived media key: `AES-ECB(mk, mk_dv)` must produce 12 leading zero bytes.
4. Derives VUK from the validated media key and Volume ID.
### Path 4: MKB + Device Keys (Subset-Difference Tree)
```
MKB + device_keys --> subset-difference tree traversal --> processing_key --> media_key --> VUK
```
The most complex path. Each device key has an associated node number, UV value, and mask parameters that position it in the AACS subset-difference tree. The library:
1. Finds the subset-difference entry in the MKB that applies to the device key's node.
2. Traverses the tree using AACS-G3 key derivation: `aesg3(key, inc) = AES-DEC(key, seed) XOR seed`, where `seed[15]` is incremented by `inc`. Each tree node produces a left child (inc=0), a processing key (inc=1), and a right child (inc=2).
3. At each level, selects left or right based on the UV bit at the current position.
4. The resulting processing key is validated against the MKB cvalue to derive the media key.
5. VUK is derived from the media key and Volume ID.
## Content Decryption
### Aligned Units
AACS encrypts content in aligned units of 6144 bytes (3 sectors of 2048 bytes each). The encryption flag is signaled by the copy_permission_indicator bits in byte 0 of the unit (`unit[0] & 0xC0 != 0`).
### Per-Unit Key Derivation
Each aligned unit has its own decryption key derived from the CPS unit key:
1. **Derive**: AES-128-ECB encrypt the first 16 bytes of the unit (plaintext TP_extra_header) with the unit key.
2. **XOR**: XOR the encrypted result with the original 16 bytes to produce the per-unit decryption key.
3. **Decrypt**: AES-128-CBC decrypt bytes 16 through 6143 using the per-unit key and the fixed AACS IV.
4. **Clear flag**: Clear the encryption indicator bits (`unit[0] &= !0xC0`).
### Fixed IV
All AES-CBC operations in AACS use the same fixed initialization vector, defined in the AACS specification.
### Verification
After decryption, the library verifies correctness by checking for MPEG-TS sync bytes (0x47) at the expected 192-byte packet boundaries within the unit. Blu-ray transport stream packets are 192 bytes: 4-byte TP_extra_header followed by a 188-byte TS packet.
## Bus Encryption
### AACS 1.0
Standard Blu-ray discs do not use bus encryption. Content is read directly from the disc and decrypted using the unit key.
### AACS 2.0
UHD 4K discs add a per-sector bus encryption layer. The drive encrypts data as it is read from the disc, and the host must decrypt it before applying AACS content decryption.
Bus encryption uses a **read_data_key** obtained during the SCSI handshake. For each 2048-byte sector within an aligned unit, bytes 16 through 2047 are AES-128-CBC encrypted with the read_data_key and the fixed AACS IV. The first 16 bytes of each sector remain plaintext.
The full decryption pipeline for AACS 2.0:
1. **Bus decrypt**: For each sector, AES-128-CBC decrypt bytes 16..2047 with the read_data_key.
2. **Content decrypt**: Standard per-unit key derivation and AES-128-CBC decryption as described above.
## SCSI Handshake
The AACS SCSI authentication handshake establishes a shared bus key between host and drive, then uses it to securely transfer the Volume ID and read data keys.
### Protocol Flow
1. **Invalidate AGIDs**: Send REPORT KEY with format 0x3F for AGIDs 0-3 to clear stale sessions.
2. **Allocate AGID**: REPORT KEY format 0x00 returns a fresh Authentication Grant ID.
3. **Send host credentials**: SEND KEY format 0x01 transmits the host nonce (20 random bytes) and host certificate (92 bytes).
4. **Receive drive credentials**: REPORT KEY format 0x01 returns the drive nonce and drive certificate.
5. **Receive drive key**: REPORT KEY format 0x02 returns the drive's ephemeral EC key point and ECDSA signature over `host_nonce || drive_key_point`.
6. **Verify drive key**: The signature is verified against the drive's public key (extracted from its certificate). AACS 1.0 certificates are verified against the AACS LA public key.
7. **Send host key**: The host generates an ephemeral key pair, signs `drive_nonce || host_key_point` with the host private key, and sends via SEND KEY format 0x02.
8. **Compute bus key**: ECDH shared secret = `host_private_key * drive_key_point`. The bus key is the low 128 bits of the shared point's x-coordinate.
### Post-Authentication Reads
- **Volume ID**: REPORT DISC STRUCTURE format 0x80. Returns 16-byte VID encrypted with the bus key, plus an AES-CMAC MAC for integrity verification.
- **Read Data Keys**: REPORT DISC STRUCTURE format 0x84. Returns the read_data_key and write_data_key, each AES-ECB encrypted with the bus key.
### Elliptic Curve
AACS 1.0 uses a custom 160-bit Weierstrass curve (`y^2 = x^3 + ax + b mod p`) with 20-byte field elements. The library implements full EC arithmetic: point addition, doubling, scalar multiplication, modular inverse, ECDSA sign/verify, and ECDH key agreement.
## AACS 2.0 Status
AACS 2.0 discs are detected via the Content Certificate file (`Content000.cer` or `Content001.cer`). A certificate type byte of 0x01 indicates AACS 2.0.
AACS 2.0 drives are identified by their drive certificate type (0x11). These drives natively use P-256/SHA-256, but accept AACS 1.0 host certificates for backward compatibility.
Current implementation status:
- AACS 2.0 detection: **implemented** (Content Certificate parsing, drive cert type check)
- AACS 1.0 handshake with AACS 2.0 drives: **implemented** (backward compatibility mode)
- Full P-256 AACS 2.0 handshake: **not yet implemented** (prepared but rarely needed since drives accept AACS 1.0 host certs)
- Bus decryption with read_data_key: **implemented**
- Content decryption: **implemented** (same as AACS 1.0)
In practice, AACS 2.0 UHD discs work through the backward-compatible AACS 1.0 handshake path, with the addition of read_data_key bus decryption.
## API Usage
AACS decryption is transparent to the application. The `Disc::scan()` method handles everything automatically:
```rust
use libfreemkv::{DriveSession, Disc};
use libfreemkv::disc::ScanOptions;
use std::path::Path;
let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
// Check encryption state
if disc.encrypted {
if let Some(ref aacs) = disc.aacs {
println!("AACS {}.0", aacs.version);
println!("Key source: {}", aacs.key_source.name());
println!("Disc hash: {}", aacs.disc_hash);
if let Some(mkb_ver) = aacs.mkb_version {
println!("MKB version: {}", mkb_ver);
}
} else {
println!("Encrypted but keys not available");
}
}
// Read content -- decryption is automatic
let mut reader = disc.open_title(&mut session, 0).unwrap();
while let Some(unit) = reader.read_unit().unwrap() {
// unit is 6144 bytes of decrypted content
}
```
The application never touches keys, never calls decryption functions, and never manages handshakes. All of that is internal to `Disc::scan()` and `ContentReader::read_unit()`.
### KEYDB Location
`ScanOptions` controls where the KEYDB is loaded from. If no explicit path is set, the library checks:
1. `~/.config/aacs/KEYDB.cfg`
2. `/etc/aacs/KEYDB.cfg`
To specify an explicit path:
```rust
let opts = ScanOptions::with_keydb("/path/to/KEYDB.cfg");
let disc = Disc::scan(&mut session, &opts).unwrap();
```
### AacsState
After a successful scan, `disc.aacs` contains an `AacsState` with:
| Field | Type | Description |
|-------|------|-------------|
| `version` | `u8` | AACS version (1 or 2) |
| `bus_encryption` | `bool` | Whether bus encryption is active |
| `mkb_version` | `Option<u32>` | MKB version from disc |
| `disc_hash` | `String` | SHA-1 of Unit_Key_RO.inf (hex with 0x prefix) |
| `key_source` | `KeySource` | How keys were resolved |
| `vuk` | `[u8; 16]` | Volume Unique Key |
| `unit_keys` | `Vec<(u32, [u8; 16])>` | Decrypted unit keys (CPS unit number, key) |
| `read_data_key` | `Option<[u8; 16]>` | AACS 2.0 bus decryption key |
| `volume_id` | `[u8; 16]` | Volume ID from SCSI handshake |
### KeySource
| Variant | Description |
|---------|-------------|
| `KeyDb` | VUK found directly in KEYDB by disc hash |
| `KeyDbDerived` | Media key + Volume ID from KEYDB, VUK derived |
| `ProcessingKey` | MKB + processing keys from KEYDB |
| `DeviceKey` | MKB + device keys, subset-difference tree traversal |
## KEYDB.cfg Format Reference
The KEYDB.cfg file contains all cryptographic material needed for AACS decryption. Lines starting with `;` or `#` are comments.
### Device Keys
```
| DK | DEVICE_KEY 0x<key> | DEVICE_NODE 0x<node> | KEY_UV 0x<uv> | KEY_U_MASK_SHIFT 0x<shift>
```
- `key`: 16-byte AES device key (hex)
- `node`: Device node number in the subset-difference tree (hex)
- `uv`: UV value for tree positioning (hex)
- `shift`: U mask shift value (hex)
### Processing Keys
```
| PK | 0x<key>
```
- `key`: 16-byte pre-computed processing key (hex)
### Host Certificate
```
| HC | HOST_PRIV_KEY 0x<privkey> | HOST_CERT 0x<cert>
```
- `privkey`: 20-byte ECDSA private key (hex)
- `cert`: 92-byte AACS host certificate (hex)
The host certificate is used for SCSI authentication. It contains the host's public key and is signed by the AACS Licensing Administrator.
### Disc Entries
```
0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
```
- `disc_hash`: 20-byte SHA-1 of Unit_Key_RO.inf (hex)
- `title`: Human-readable disc title
- `D`: Date tag, followed by release/rip date
- `M`: Media key tag, followed by 16-byte media key (hex)
- `I`: Disc ID tag, followed by 16-byte Volume ID (hex)
- `V`: VUK tag, followed by 16-byte Volume Unique Key (hex)
- `U`: Unit keys tag, followed by space-separated `<unit_num>-0x<key>` pairs
All fields after the title are optional. A minimal entry needs only the disc hash and VUK:
```
0x<disc_hash> = <title> | V | 0x<vuk>
```
Inline comments are supported with `;`:
```
0x<disc_hash> = <title> | V | 0x<vuk> ; MKBv77
```
+180
View File
@@ -0,0 +1,180 @@
# libfreemkv Architecture
Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD.
Rust library with no external dependencies at runtime -- profiles are bundled,
AACS keys are derived internally, and all SCSI communication is handled in-process.
**Repository:** <https://github.com/freemkv/libfreemkv>
**License:** AGPL-3.0-only
---
## Design Principles
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
format handling live in the library. CLI binaries are thin wrappers that call
`DriveSession::open()` and `Disc::scan()`.
2. **No external files.** 206 drive profiles are compiled into the binary via
`include_str!`. No configuration directory, no runtime file lookups for drive
support.
3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are
available. Callers read cleartext sectors without knowing whether the disc
was encrypted.
4. **Structured errors, no English.** Every error has a numeric code (E1000-E7000).
The library never formats user-facing messages -- applications do that.
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
policy level. If a profile exists, the library uses it.
---
## Module Map
```
libfreemkv (lib.rs)
│
├── Drive Access
│ ├── drive DriveSession — open, identify, unlock, read
│ ├── scsi ScsiTransport trait + SG_IO implementation
│ ├── platform/ Platform trait — per-chipset command handlers
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, hp)
│ ├── profile DriveProfile loading, matching, bundled JSON
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
│ └── speed DriveSpeed enum, SET CD SPEED CDB builder
│
├── Disc Scanning
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
│ ├── mpls MPLS playlist parser — clips, streams, STN table
│ ├── clpi CLPI clip info parser — EP map, sector extents
│ └── jar BD-J JAR label extraction (audio/subtitle names)
│
├── Encryption
│ ├── aacs KEYDB parsing, VUK lookup, MKB processing, unit decryption
│ └── aacs_handshake ECDH bus authentication, Volume ID, Read Data Key
│
└── error Error enum with numeric codes E1000-E7000
```
---
## Drive Access Flow
```
DriveSession::open("/dev/sr0")
│
├─ scsi::open() Open /dev/sr0 via SG_IO
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
├─ profile::find_by_drive_id() Match against 206 bundled profiles
├─ Platform::new() Instantiate chipset driver (Mt1959)
└─ Platform::unlock() Activate raw disc access mode
```
After open, the session provides:
- `read_sectors(lba, count, buf)` -- raw sector reads (through platform driver)
- `read_disc(lba, count, buf)` -- standard READ(10) for filesystem data
- `scsi_execute(cdb, dir, buf, timeout)` -- arbitrary SCSI commands
- `status()`, `calibrate()`, `read_config()`, `read_register()`
---
## Disc Scanning Flow
```
Disc::scan(&mut session, &ScanOptions)
│
├─ READ CAPACITY Get disc size in sectors
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
├─ For each BDMV/PLAYLIST/*.mpls:
│ ├─ mpls::parse() Extract play items, STN streams
│ └─ For each clip:
│ └─ clpi::parse() EP map → sector extents for the clip's time range
├─ Detect AACS Check for /AACS directory on disc
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
```
The result is a `Disc` with:
- `titles: Vec<Title>` -- sorted by duration, each with streams and sector extents
- `aacs: Option<AacsState>` -- decryption keys if available
- `encrypted: bool` -- whether the disc uses AACS
---
## AACS Decryption
Four key resolution paths, tried in order:
| Path | Method | Speed |
|------|--------|-------|
| 1 | VUK lookup by disc hash in KEYDB.cfg | Instant |
| 2 | Media Key + Volume ID from KEYDB → derive VUK | Fast |
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
The AACS handshake (`aacs_handshake`) performs ECDH key agreement over the
AACS 1.0 160-bit elliptic curve to obtain:
- **Volume ID** -- needed for VUK derivation (paths 2-4)
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
`ContentReader` handles this transparently.
---
## Error Codes
All errors carry a numeric code for programmatic handling. No user-facing text
is baked into the library.
| Range | Category | Examples |
|-------|----------|----------|
| E1xxx | Device errors | `DeviceNotFound`, `DevicePermission` |
| E2xxx | Profile errors | `UnsupportedDrive`, `ProfileNotFound`, `ProfileParse` |
| E3xxx | Unlock errors | `UnlockFailed`, `SignatureMismatch`, `NotUnlocked`, `NotCalibrated` |
| E4xxx | SCSI errors | `ScsiError`, `ScsiTimeout` |
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
---
## Platform Support
| Platform | Transport | Status |
|----------|-----------|--------|
| Linux | SG_IO ioctl on `/dev/sr*` | Implemented |
| macOS | IOKit SCSI passthrough | Planned |
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Planned |
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
implementing `execute()` for that OS and wiring it into `scsi::open()`.
---
## Chipset Support
| Chipset | Drives | Status |
|---------|--------|--------|
| MediaTek MT1959 | LG, ASUS, hp | Implemented (206 profiles) |
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
The `Platform` trait abstracts chipset-specific commands. Each chipset implements
10 handlers (unlock, config, register, calibrate, keepalive, status, probe,
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
chipset-specific mode and buffer ID bytes.
---
## Build
```
cargo build --release
```
Linux builds produce a static library and two binaries (`freemkv-info`,
`freemkv-test`). The `libc` dependency is Linux-only. On non-Linux platforms,
the library compiles but `scsi::open()` returns a platform-not-supported error
until the IOKit/SPTI backends are implemented.
+197
View File
@@ -0,0 +1,197 @@
# CLPI Clip Information Format
## What is CLPI?
CLPI (Clip Information) files describe the structure of individual M2TS transport streams on a Blu-ray disc. Each `.clpi` file in `BDMV/CLIPINF/` corresponds to one `.m2ts` file in `BDMV/STREAM/` with the same numeric name (e.g. `00001.clpi` describes `00001.m2ts`).
The most important data in a CLPI file is the **EP (Entry Point) map**, which provides timestamp-to-sector mapping. This is essential for seeking and for extracting the specific sector ranges that correspond to a playlist's in/out time window.
## File Structure
CLPI files use big-endian byte order. The header:
```
Offset Size Field
------ ---- -----
0 4 Magic: "HDMV"
4 4 Version: "0200" (BD) or "0300" (UHD BD)
8 4 SequenceInfo start offset
12 4 ProgramInfo start offset
16 4 CPI (Characteristic Point Information) start offset
20 4 ClipMark start offset
24 4 ExtensionData start offset
```
### ClipInfo Section (offset 40)
Contains basic clip metadata. The source packet count (total number of 192-byte source packets in the M2TS file) is at offset 56:
```
Offset Size Field
------ ---- -----
40 4 ClipInfo length
44 2 Reserved
46 1 Stream type
47 1 Application type
48 4 Reserved
52 4 TS recording rate
56 4 Source packet count
```
The source packet count multiplied by 192 gives the total byte size of the M2TS file.
## EP Map
The EP map lives inside the CPI section and provides random access into the transport stream. It maps PTS timestamps to Source Packet Numbers (SPN), enabling precise seeking without scanning the stream.
### CPI Section Layout
```
Offset Size Field
------ ---- -----
0 4 CPI length
4 2 Reserved / CPI type
6 ... EP map
```
### EP Map Header
```
Offset Size Field
------ ---- -----
0 1 Reserved
1 1 Number of stream PID entries
2 ... Stream PID entry headers (one per stream)
```
Each stream PID entry header (14 bytes):
```
Offset Size Field
------ ---- -----
0 2 Stream PID
2 2 Reserved + EP stream type
4 2 Number of coarse entries
6 4 Number of fine entries (note: 32-bit, can be large)
10 4 EP map start offset (relative to EP map start)
```
libfreemkv parses only the first stream (primary video), which is sufficient for sector-level seeking.
### Two-Level Index
The EP map uses a two-level structure to compress what would otherwise be a very large lookup table:
- **Coarse entries**: low-resolution index covering large time/sector ranges
- **Fine entries**: high-resolution entries within each coarse range
Each coarse entry points to a range of fine entries via `ref_to_fine_id`.
### Coarse Entries (8 bytes each)
Located immediately after the fine table start offset (4 bytes) in the per-stream EP map:
```
Bits Field
------ -----
[31:14] ref_to_fine_id (18 bits) -- index of first fine entry in this range
[13:0] pts_coarse (14 bits) -- upper bits of PTS
```
Second dword:
```
Bits Field
------ -----
[31:0] spn_coarse (32 bits) -- upper bits of SPN
```
### Fine Entries (4 bytes each)
Located at the fine table start offset within the per-stream EP map:
```
Bits Field
------ -----
[31] is_angle_change_point (1 bit)
[30:28] I_end_position_offset (3 bits)
[27:17] pts_fine (11 bits) -- lower bits of PTS
[16:0] spn_fine (17 bits) -- lower bits of SPN
```
## Reconstructing Full PTS and SPN
The full timestamp and packet number are assembled by combining the coarse and fine components:
### Full PTS
```
full_pts = (pts_coarse << 19) + (pts_fine << 8)
```
The coarse component provides the upper bits, shifted left by 19. The fine component provides mid-range bits, shifted left by 8. The resulting PTS is in 45kHz ticks, matching the MPLS timestamp format.
### Full SPN
```
full_spn = (spn_coarse & 0xFFFE0000) + spn_fine
```
The coarse SPN provides the upper 15 bits (masked to clear the lower 17). The fine SPN provides the lower 17 bits. The result is a Source Packet Number -- each source packet is 192 bytes in the M2TS file.
### Resolved EP Map
The `resolved_ep_map()` method iterates through all coarse entries and their associated fine entries to produce a flat list of `(PTS, SPN)` pairs. For each coarse entry at index `ci`:
- Fine entries range from `coarse[ci].ref_to_fine_id` to `coarse[ci+1].ref_to_fine_id` (or end of fine table for the last coarse entry).
- Each fine entry is combined with its parent coarse entry to produce one full `(PTS, SPN)` pair.
## Deriving Sector Extents for Ripping
Given a playlist's in_time and out_time (from MPLS), the CLPI EP map provides the sector ranges to read from disc. The `get_extents()` method does this in three steps:
### Step 1: PTS to SPN
Binary search the resolved EP map for the in_time and out_time:
- **start_spn**: the SPN at or before in_time (seek backward to the nearest I-frame)
- **end_spn**: the SPN at or after out_time (include the full GOP)
### Step 2: SPN to Byte Offset
Each Source Packet is 192 bytes (188 bytes MPEG-TS payload + 4 bytes M2TS header):
```
byte_offset = spn * 192
```
### Step 3: Byte Offset to Sector
M2TS files are stored contiguously on disc. Sectors are 2048 bytes:
```
start_sector = start_byte / 2048
end_sector = (end_byte + 2047) / 2048
sector_count = end_sector - start_sector
```
The resulting `Extent` contains `start_lba` (relative to the M2TS file's starting sector on disc) and `sector_count`. The caller adds the file's absolute starting LBA from UDF to get disc-absolute sector numbers.
### Alignment Note
The 192-byte source packet size and 2048-byte sector size share no common factor beyond 1. A single sector contains roughly 10.67 source packets. The conversion rounds start down and end up to ensure complete coverage.
## Putting It Together
The full ripping pipeline chains three parsers:
1. **MPLS** provides clip IDs and in/out timestamps.
2. **CLPI** converts those timestamps to SPN ranges, then to sector extents.
3. **UDF** provides the file's starting LBA on disc for absolute sector addressing.
The `Disc::scan()` method in `src/disc.rs` orchestrates this: for each play item in each playlist, it loads the corresponding CLPI, calls `get_extents()` with the play item's in/out times, and collects the resulting sector ranges into the title's extent list.
## References
- BD-ROM Part 3, Section 5.5: Clip Information file format
- https://github.com/lw/BluRay/wiki/CLPI
+114
View File
@@ -0,0 +1,114 @@
# Disc to Rip: End-to-End Flow
How libfreemkv goes from a disc in the drive to decrypted content ready for backup.
This is the starting point for understanding the library.
## The Pipeline
```
Insert disc
│
▼
1. Open drive (drive.rs)
│ INQUIRY → identify drive
│ Match bundled profile → chipset, unlock parameters
│
▼
2. AACS handshake (aacs_handshake.rs) — optional, separate transport
│ Allocate AGID
│ Exchange certificates + nonces (ECDH)
│ Derive bus key
│ Read Volume ID + read_data_key
│ (fails gracefully if drive doesn't support AACS for this disc)
│
▼
3. Unlock drive (drive.rs → platform/mt1959.rs)
│ Vendor-specific command activates raw read mode
│ Required — drive firmware blocks all reads without it
│
▼
4. Read UDF filesystem (udf.rs)
│ Sector 256: AVDP → find Volume Descriptor Sequence
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
│ Metadata partition → File Set Descriptor → Root directory
│ Walk directory tree: BDMV/, AACS/, CERTIFICATE/
│ → docs/udf.md
│
▼
5. Read AACS files from disc (aacs.rs)
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
│ MKB via SCSI → for key derivation fallback
│
▼
6. Resolve AACS keys (aacs.rs → resolve_keys)
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
│ Path 2: KEYDB media key + Volume ID → VUK
│ Path 3: MKB + processing keys → media key → VUK
│ Path 4: MKB + device keys → subset-difference tree → VUK
│ VUK → decrypt unit keys from Unit_Key_RO.inf
│ → docs/aacs.md
│
▼
7. Parse playlists (mpls.rs)
│ BDMV/PLAYLIST/*.mpls → titles with play items
│ Each play item: clip ID, in/out timestamps
│ STN table: video, audio, subtitle streams with codec + language
│ → docs/mpls.md
│
▼
8. Parse clip info (clpi.rs)
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
│ Coarse + fine entries → full PTS and SPN
│ SPN → byte offset → sector extents for reading
│ → docs/clpi.md
│
▼
9. Parse BD-J labels (jar.rs) — optional
│ BDMV/JAR/*.jar → Java class constant pool strings
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
│
▼
10. Read + decrypt content (disc.rs → ContentReader)
│ For each aligned unit (6144 bytes = 3 sectors):
│ Read 3 sectors from disc
│ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC)
│ If encrypted: unit decrypt (per-unit key derivation + AES-CBC)
│ Output decrypted content
│
▼
Decrypted m2ts stream → ready for muxing/backup
```
## API Summary
```rust
// Steps 1 + 3 (open + unlock)
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
// Steps 2 + 4-9 (AACS + scan)
let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?;
// Step 10 (read + decrypt)
let mut reader = disc.open_title(&mut session, 0)?;
while let Some(unit) = reader.read_unit()? {
output.write_all(&unit)?;
}
```
Three lines. Everything else is internal.
## Module Reference
| Module | Doc | Purpose |
|--------|-----|---------|
| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read |
| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport |
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt |
| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication |
| disc.rs | -- | High-level scan + read API |
| jar.rs | -- | BD-J audio track labels |
| error.rs | -- | Error codes (E1xxx-E7xxx) |
+253
View File
@@ -0,0 +1,253 @@
# Drive Access and Unlock
Technical reference for how libfreemkv opens, identifies, unlocks, and reads
optical drives.
---
## DriveSession
`DriveSession` is the primary API. It owns the SCSI transport, the matched
drive profile, and the chipset-specific platform driver.
### Opening a Drive
```rust
// Full open: identify → match profile → unlock
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
// No-unlock open: identify → match profile only
let mut session = DriveSession::open_no_unlock(Path::new("/dev/sr0"))?;
// Explicit profile (skip auto-detection)
let mut session = DriveSession::open_with_profile(Path::new("/dev/sr0"), profile)?;
```
**`open()`** performs the full sequence: open device, send INQUIRY, match
profile, instantiate platform driver, and unlock. Unlock failures are silently
ignored (unencrypted discs do not need it). After `open()`, both raw sector
reads and standard READ(10) work immediately.
**`open_no_unlock()`** skips the unlock step. This is required when AACS bus
authentication must happen before unlock. The handshake uses standard SCSI
commands that work without raw mode. After authentication completes, the caller
can invoke `session.unlock()` manually.
**`open_with_profile()`** bypasses profile auto-detection. Useful for testing
or when a custom profile is loaded from an external source.
### Session Operations
| Method | Description |
|--------|-------------|
| `unlock()` | Activate raw disc access mode via platform driver |
| `is_unlocked()` | Check if raw mode is active |
| `calibrate()` | Build speed lookup table for the current disc |
| `read_sectors(lba, count, buf)` | Raw sector read (requires unlock + calibrate) |
| `read_disc(lba, count, buf)` | Standard READ(10) with 5s timeout |
| `status()` | Query drive status and feature flags |
| `read_config()` | Read drive configuration block (1888 bytes) |
| `read_register(index)` | Read 16-byte hardware register |
| `probe(sub_cmd, addr, len)` | Generic READ BUFFER with caller parameters |
| `scsi_execute(cdb, dir, buf, timeout)` | Send an arbitrary SCSI CDB |
---
## SCSI Transport
### Trait
```rust
pub trait ScsiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
}
```
All drive communication goes through this trait. The library never opens file
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
### Linux: SG_IO
The `SgIoTransport` implementation:
1. Opens the device path with `O_RDWR | O_NONBLOCK`.
2. Constructs an `sg_io_hdr` struct with the CDB, data buffer, and timeout.
3. Calls `ioctl(fd, SG_IO, &hdr)`.
4. Returns `ScsiResult` with status, bytes transferred, and sense data.
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
sense buffer and returns `Error::ScsiError`.
### CDB Builders
The `scsi` module provides platform-agnostic CDB constructors:
| Function | CDB | Use |
|----------|-----|-----|
| `inquiry()` | INQUIRY (0x12) | Drive identification |
| `get_config_010c()` | GET CONFIGURATION (0x46) | Feature 010C firmware date |
| `build_read_buffer()` | READ BUFFER (0x3C) | All platform commands |
| `build_set_cd_speed()` | SET CD SPEED (0xBB) | Speed control |
| `build_read10_raw()` | READ(10) (0x28) with flag 0x08 | Raw sector reads |
---
## Drive Identification
`DriveId::from_drive()` sends two standard SCSI commands and extracts identity
fields:
| Field | Source | SCSI Reference |
|-------|--------|----------------|
| `vendor_id` | INQUIRY bytes [8:16] | SPC-4 section 6.4.2 |
| `product_id` | INQUIRY bytes [16:32] | SPC-4 section 6.4.2 |
| `product_revision` | INQUIRY bytes [32:36] | SPC-4 section 6.4.2 |
| `vendor_specific` | INQUIRY bytes [36:43] | SPC-4 section 6.4.2 |
| `firmware_date` | GET CONFIGURATION Feature 010C | MMC-6 section 5.3.10 |
The match key is `"VENDOR|PRODUCT|REVISION|VENDOR_SPECIFIC"`. Profile matching
tries all four fields first, then falls back to matching without the firmware
date for drives where Feature 010C is unavailable.
---
## Drive Profiles
Profiles are JSON objects compiled into the binary (`profiles.json`,
206 entries). Each profile contains:
| Field | Purpose |
|-------|---------|
| `vendor_id`, `product_revision`, `vendor_specific`, `firmware_date` | Matching fields |
| `chipset` | `"mediatek"` or `"renesas"` |
| `unlock_mode`, `unlock_buf_id` | READ BUFFER CDB parameters |
| `signature` | Expected 4-byte response signature |
| `unlock_cdb` | Pre-built unlock CDB (hex-encoded) |
| `register_offsets` | Offsets for hardware register reads |
| `capabilities` | Feature flags: `bd_raw_read`, `dvd_all_regions`, etc. |
Loading:
```rust
// Bundled (compiled-in) -- no file I/O
let profiles = profile::load_bundled()?;
// External file
let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
```
---
## Chipsets
### MediaTek MT1959
Covers all LG, ASUS, and hp optical drives. Two sub-variants share identical
logic with different SCSI parameters:
| Variant | READ BUFFER mode | Buffer ID |
|---------|------------------|-----------|
| MT1959-A | 0x01 | 0x44 |
| MT1959-B | 0x02 | 0x77 |
The Platform trait maps to 10 command handlers:
| Handler | Function | Description |
|---------|----------|-------------|
| 0 | `unlock()` | Send READ BUFFER, verify signature + verification bytes |
| 1 | `read_config()` | Read 1888-byte configuration block + 4-byte status |
| 2-3 | `read_register()` | Read hardware registers at profile-specified offsets |
| 4 | `calibrate()` | Probe disc surface, build 64-entry speed table |
| 5 | `keepalive()` | Periodic session maintenance |
| 6 | `status()` | Query current mode and feature flags |
| 7 | `probe()` | Generic READ BUFFER with dynamic parameters |
| 8 | `read_sectors()` | Speed lookup + SET CD SPEED + READ(10) with flag 0x08 |
| 9 | `timing()` | Timing calibration |
### Renesas (Planned)
RS8xxx/RS9xxx chipsets used in Pioneer and some HL-DT-ST drives.
Currently returns `Error::UnsupportedDrive` when a Renesas profile is matched.
---
## Why Unlock Is Needed
Optical drive firmware restricts what applications can read from disc. Without
unlock:
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
playlists, and CLPI clip info are readable without unlock. The `read_disc()`
method uses standard READ(10) and works on any drive.
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
SCSI errors (sense key 0x05, illegal request) when an application attempts to
read sectors containing encrypted m2ts content without prior AACS
authentication via the bus key.
- **The kernel sr driver blocks block-device reads.** On Linux, the kernel's
SCSI CD-ROM driver (`sr`) refuses to expose encrypted disc content through
`/dev/sr0` as a block device. Even if you open the block device directly,
reads to encrypted regions fail.
- **Raw mode bypasses firmware restrictions.** After unlock, the drive accepts
READ(10) with the raw read flag (CDB byte 1 = 0x08) for all sectors,
regardless of encryption status. This is how raw sector ripping works.
### open() vs open_no_unlock()
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
These must execute before unlock because:
1. The AACS handshake establishes a bus key via ECDH.
2. The bus key encrypts the Volume ID and Read Data Key responses.
3. The Volume ID is needed to derive the Volume Unique Key (VUK).
4. The VUK is needed to decrypt unit keys from `Unit_Key_RO.inf`.
If `open()` unlocks first, some drives reject the subsequent AACS commands.
The correct sequence for encrypted discs is:
```rust
// 1. Open without unlock
let mut session = DriveSession::open_no_unlock(device)?;
// 2. AACS handshake (uses standard SCSI, no unlock needed)
let auth = aacs_handshake::aacs_authenticate(&mut session, &key, &cert)?;
let vid = aacs_handshake::read_volume_id(&mut session, &mut auth)?;
// 3. Now unlock for raw reads
session.unlock()?;
session.calibrate()?;
// 4. Read and decrypt content
session.read_sectors(lba, count, &mut buf)?;
```
In practice, `Disc::scan()` handles this internally. The default `open()` call
unlocks immediately and is correct for most use cases -- the scan re-opens a
second session with `open_no_unlock()` for the AACS handshake when needed.
---
## Speed Control
After `calibrate()`, the platform driver maintains a 64-entry speed lookup table
built by probing the disc surface. On each `read_sectors()` call, the driver:
1. Looks up the optimal speed for the target LBA in the table.
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
3. Performs the READ(10).
Available speeds:
| Format | Speeds |
|--------|--------|
| Blu-ray | 1x (4,500 KB/s) through 12x (54,000 KB/s) |
| DVD | 1x (1,385 KB/s) through 16x (22,160 KB/s) |
| Max | 0xFFFF (drive decides) |
+217
View File
@@ -0,0 +1,217 @@
# MPLS Playlist Format
## What is MPLS?
MPLS (Movie PlayList) files define playback titles on a Blu-ray disc. Each `.mpls` file in `BDMV/PLAYLIST/` describes one title -- a sequence of clips with precise in/out timestamps and stream information. The main movie, bonus features, trailers, and menus each have their own MPLS file.
A disc may contain dozens of MPLS files. Most are short (menus, logos, transitions). The main movie is typically the longest playlist. libfreemkv filters out playlists shorter than 30 seconds.
## File Structure
MPLS files use big-endian byte order throughout. The file has three main sections:
```
Offset Size Field
------ ---- -----
0 4 Magic: "MPLS"
4 4 Version: "0200" (BD) or "0300" (UHD BD)
8 4 PlayList start offset (absolute from file start)
12 4 PlayListMark start offset
16 4 ExtensionData start offset
```
### PlayList Section
Located at the PlayList start offset. Contains all play items and sub-path entries:
```
Offset Size Field
------ ---- -----
0 4 PlayList length
4 2 Reserved
6 2 Number of PlayItems
8 2 Number of SubPaths
10 ... PlayItem entries (variable length)
```
### Play Items
Each play item references one clip and specifies what portion to play:
```
Offset Size Field
------ ---- -----
0 2 PlayItem length (bytes after this field)
2 5 Clip ID (ASCII, e.g. "00001")
7 4 Codec ID ("M2TS")
11 1 Connection condition (lower 4 bits)
12 1 Ref to STC_id
14 4 IN_time (45kHz PTS ticks)
18 4 OUT_time (45kHz PTS ticks)
22 8 UO_mask_table
30 1 Misc flags
31 1 still_mode
32 ... STN_table (first play item only is parsed)
```
**Connection condition** values:
- `1` = seamless connection (no gap between clips)
- `5`, `6` = non-seamless connection
**Timestamps** use 45kHz PTS (Presentation Time Stamp) ticks, the same timebase as MPEG transport streams. Duration of a play item = `OUT_time - IN_time`. To convert to seconds: divide by 45000.
A playlist's total duration is the sum of all play item durations.
### Clip ID Mapping
The Clip ID (e.g. "00001") maps to:
- `BDMV/STREAM/00001.m2ts` -- the transport stream
- `BDMV/CLIPINF/00001.clpi` -- the clip info (EP map, stream details)
## STN Table
The Stream Number Table describes all elementary streams available in the clip. It is parsed from the first play item (which defines the title's stream layout).
```
Offset Size Field
------ ---- -----
0 2 STN table length
2 2 Reserved
4 1 Number of primary video streams
5 1 Number of primary audio streams
6 1 Number of PG (subtitle) streams
7 1 Number of IG (interactive graphics) streams
8 ... Stream entries
```
### Stream Entries
Each stream entry has two parts: a stream reference and a stream attributes block.
**Stream reference:**
```
Offset Size Field
------ ---- -----
0 1 Entry length
1 1 Stream type (1=PlayItem, 2=SubPath, 3=InMux)
2 2 PID (MPEG-TS packet ID, big-endian)
```
**Stream attributes** (immediately follows the reference):
```
Offset Size Field
------ ---- -----
0 1 Attributes length
1 1 Coding type
2+ ... Type-specific fields
```
The layout of type-specific fields depends on the stream category:
**Video streams:**
| Offset | Size | Field |
|--------|------|-------|
| 1 | 1 | Coding type |
| 2 | 1 | Format (upper 4 bits) + frame rate (lower 4 bits) |
**Audio streams:**
| Offset | Size | Field |
|--------|------|-------|
| 1 | 1 | Coding type |
| 2 | 1 | Format (upper 4 bits) + sample rate (lower 4 bits) |
| 3 | 3 | Language code (ISO 639-2, e.g. "eng") |
**PG subtitle and IG streams:**
| Offset | Size | Field |
|--------|------|-------|
| 1 | 1 | Coding type |
| 2 | 3 | Language code |
## Coding Types
The coding type byte identifies the codec:
| Value | Codec | Category |
|-------|-------|----------|
| `0x02` | MPEG-2 | Video |
| `0x1B` | H.264 / AVC | Video |
| `0x24` | HEVC / H.265 | Video |
| `0xEA` | VC-1 | Video |
| `0x80` | LPCM | Audio |
| `0x81` | AC-3 (Dolby Digital) | Audio |
| `0x82` | DTS | Audio |
| `0x83` | TrueHD (Dolby TrueHD) | Audio |
| `0x84` | AC-3 Plus (E-AC-3) | Audio |
| `0x85` | DTS-HD HR | Audio |
| `0x86` | DTS-HD MA | Audio |
| `0xA1` | AC-3 Plus (secondary) | Audio |
| `0xA2` | DTS-HD HR (secondary) | Audio |
| `0x90` | PGS (Presentation Graphics) | Subtitle |
| `0x91` | PGS (Interactive Graphics) | Subtitle |
## Video Format Codes
| Value | Resolution |
|-------|-----------|
| 1 | 480i |
| 2 | 576i |
| 3 | 480p |
| 4 | 1080i |
| 5 | 720p |
| 6 | 1080p |
| 7 | 576p |
| 8 | 2160p |
## Video Frame Rate Codes
| Value | Frame Rate |
|-------|-----------|
| 1 | 23.976 fps |
| 2 | 24 fps |
| 3 | 25 fps |
| 4 | 29.97 fps |
| 6 | 50 fps |
| 7 | 59.94 fps |
## Audio Format Codes
| Value | Channels |
|-------|----------|
| 1 | Mono |
| 3 | Stereo |
| 6 | 5.1 surround |
| 12 | 7.1 surround |
## Audio Sample Rate Codes
| Value | Rate |
|-------|------|
| 1 | 48 kHz |
| 4 | 96 kHz |
| 5 | 192 kHz |
| 12 | 48/192 kHz (combo) |
| 14 | 48/96 kHz (combo) |
## How Playlists Map to Titles
libfreemkv's `Disc::scan()` reads every MPLS file from the disc and builds a `Title` for each:
1. Read all `.mpls` files from `BDMV/PLAYLIST/` via UDF.
2. Parse each with `mpls::parse()`.
3. Calculate duration by summing `(OUT_time - IN_time)` across all play items.
4. Discard playlists shorter than 30 seconds.
5. For each play item, load the corresponding CLPI file to get EP map data and compute sector extents (see [clpi.md](clpi.md)).
6. Extract stream info from the STN table of the first play item.
7. Sort titles by duration, longest first.
The resulting `Title` struct contains everything needed to rip: streams, duration, byte size, and the sector extents to read from disc.
## References
- BD-ROM Part 3, Section 5.3: PlayList file format
- https://github.com/lw/BluRay/wiki/MPLS
+169
View File
@@ -0,0 +1,169 @@
# UDF 2.50 Filesystem Parser
## What is UDF?
UDF (Universal Disc Format) is the filesystem standard for optical media. It is defined by ECMA-167 with extensions from the OSTA (Optical Storage Technology Association). BD-ROM discs use UDF revision 2.50, which introduces the **metadata partition** -- a critical feature that separates file metadata from file content on disc.
## Why UDF 2.50 for Blu-ray?
Older UDF revisions (1.02, 1.50) scatter ICBs (file metadata) and file data across the same partition. On a high-capacity Blu-ray disc (25-100 GB), this creates excessive seeking when the drive needs to read a directory listing or locate a file. UDF 2.50 solves this by placing all metadata into a contiguous region near the beginning of the disc. The drive reads metadata from one compact area and streams file data from another -- no interleaved seeks.
BD-ROM Part 3 of the Blu-ray specification mandates UDF 2.50.
## Metadata Partitions
A UDF 2.50 BD-ROM has two logical partitions:
- **Partition 0 (Type 1)** -- the physical partition. Contains actual file data (m2ts streams, playlist files, etc.). Mapped directly to disc sectors starting at the Partition Descriptor's `partitionStartingLocation`.
- **Partition 1 (Type 2)** -- the metadata partition. Contains all ICBs (Inode-like structures), directory data, and the File Set Descriptor. The metadata partition is itself stored as a file within the physical partition. Its location is found by reading an Extended File Entry at LBA 0 of the physical partition.
The key rule: **ICBs and directory data live in the metadata partition. File content lives in the physical partition.** When an ICB's allocation descriptor gives an LBA, the partition it refers to depends on what the LBA describes -- metadata-relative for directory entries, physical-partition-relative for file data extents.
## Pointer Chain
Reading a UDF 2.50 filesystem follows a fixed chain of pointers. Each step reads one or two 2048-byte sectors:
```
Sector 256: AVDP (Anchor Volume Descriptor Pointer, tag 2)
|
v
Sectors 32-63: VDS (Volume Descriptor Sequence)
|-- Partition Descriptor (tag 5) --> partition_start (physical sector)
|-- Logical Volume Descriptor (tag 6) --> partition maps, FSD location
|
v
Partition Maps in LVD (offset 440):
|-- Map 0: Type 1 (physical partition)
|-- Map 1: Type 2 (metadata partition, identified by "*UDF Metadata Partition")
|
v
Metadata file ICB at partition_start + 0 (Extended File Entry, tag 266)
|-- Allocation descriptor --> metadata content location
|
v
metadata_start = partition_start + allocation_position
|
v
FSD at metadata_start + 0 (File Set Descriptor, tag 256)
|-- Root Directory ICB: long_ad at offset 400 --> root_lba (metadata-relative)
|
v
Root Directory ICB at metadata_start + root_lba (Extended File Entry, tag 266)
|-- Allocation descriptor --> directory data location (metadata-relative)
|
v
Directory data: File Identifier Descriptors (tag 257)
|-- Each FID names a file/subdirectory and points to its ICB
|-- Recurse into subdirectories to build the full file tree
```
### AVDP (Sector 256)
The Anchor Volume Descriptor Pointer is always at sector 256 (ECMA-167 section 10.2). It points to the Main Volume Descriptor Sequence. Tag identifier = 2.
### VDS (Sectors 32+)
The Volume Descriptor Sequence contains:
- **Partition Descriptor (tag 5)**: byte offset 188 holds `partitionStartingLocation` -- the absolute sector where the physical partition begins.
- **Logical Volume Descriptor (tag 6)**: byte offset 268 holds the number of partition maps. The partition maps themselves start at offset 440. For BD-ROM, map 0 is Type 1 (physical) and map 1 is Type 2 (metadata).
- **Terminating Descriptor (tag 8)**: signals the end of the VDS.
### Metadata File
When two partition maps exist and the second is Type 2, the metadata partition content is located by reading the Extended File Entry at the first sector of the physical partition (partition_start + 0). This ICB's allocation descriptor gives the offset and length of the metadata content within the physical partition.
### File Set Descriptor
The FSD (tag 256) sits at metadata-relative LBA 0 (the first sector of the metadata content). It contains a long allocation descriptor at offset 400 pointing to the root directory ICB. The LBA in this long_ad is at bytes 404-407.
### Directory Traversal
Each directory is an ICB (Extended File Entry, tag 266, or File Entry, tag 261) whose allocation extent points to directory data. The directory data is a sequence of File Identifier Descriptors (FIDs, tag 257):
| FID Field | Offset | Size | Description |
|-----------|--------|------|-------------|
| Tag | 0 | 2 | Always 257 |
| File characteristics | 18 | 1 | Bit 1 = directory, bit 3 = parent |
| L_FI (name length) | 19 | 1 | Length of filename |
| ICB (long_ad) | 20 | 16 | Points to the entry's ICB |
| L_IU | 36 | 2 | Implementation use length |
| Filename | 38 + L_IU | L_FI | UDF-encoded filename |
FIDs are 4-byte aligned. The parser advances by `(38 + L_IU + L_FI + 3) & !3` bytes per entry.
### ICB Layout
Both File Entry (tag 261) and Extended File Entry (tag 266) share the same info_length field:
| Field | Tag 261 Offset | Tag 266 Offset |
|-------|---------------|---------------|
| info_length (u64) | 56 | 56 |
| L_EA (u32) | 168 | 208 |
| L_AD (u32) | 172 | 212 |
| Allocation descriptors | 176 + L_EA | 216 + L_EA |
Allocation descriptors use the Short Allocation Descriptor format: 4 bytes extent length (upper 2 bits = type), 4 bytes extent position (LBA).
## How read_filesystem() Works
The `read_filesystem()` function in `src/udf.rs` follows the pointer chain above:
1. Reads sector 256, validates AVDP (tag 2).
2. Scans sectors 32-63 for the Partition Descriptor and Logical Volume Descriptor.
3. If two partition maps exist and the second is Type 2, reads the metadata file ICB at partition_start to find metadata_start.
4. Reads the FSD at metadata_start, extracts the root directory ICB LBA.
5. Calls `read_directory()` recursively (max depth 3) to build the full file tree.
Each directory read involves two sector reads: one for the ICB, then one or more for the directory data. File sizes are read from info_length in each file's ICB.
`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start).
### UDF Filename Encoding
UDF filenames use a compression ID as the first byte:
- `8` = 8-bit characters (ASCII)
- `16` = 16-bit big-endian Unicode (UTF-16BE)
The parser handles both encodings. All path lookups are case-insensitive.
## BD-ROM Directory Structure
A typical Blu-ray disc has this directory layout:
```
/
+-- BDMV/
| +-- index.bdmv Disc index (title list, first play)
| +-- MovieObject.bdmv Movie objects (navigation commands)
| +-- PLAYLIST/
| | +-- 00000.mpls Main movie playlist
| | +-- 00001.mpls Director's commentary
| | +-- ...
| +-- CLIPINF/
| | +-- 00001.clpi Clip info for 00001.m2ts
| | +-- 00002.clpi
| | +-- ...
| +-- STREAM/
| | +-- 00001.m2ts Transport stream (video/audio/subtitle data)
| | +-- 00002.m2ts
| | +-- ...
| +-- BACKUP/ Duplicate of index, MovieObject, playlists, clip info
|
+-- AACS/ AACS encryption data (encrypted discs only)
| +-- Unit_Key_RO.inf Unit key file (encrypted)
| +-- MKB_RW.inf Media Key Block
| +-- Content000.cer Content certificate
| +-- DUPLICATE/ Backup copies
|
+-- CERTIFICATE/ BD+ certificate data (some discs)
```
The parser reads from `BDMV/PLAYLIST/` and `BDMV/CLIPINF/` to discover titles and their sector layouts. The `BDMV/STREAM/` directory contains the actual transport streams but is not parsed by the UDF layer -- stream data is read by LBA directly using extents computed from CLPI EP maps.
## References
- ECMA-167: Volume and File Structure of Write-Once and Rewritable Media
- UDF 2.50 (OSTA): Universal Disk Format Specification
- BD-ROM Part 3: Blu-ray Disc Read-Only Format, File System Specifications
+3710 -1445
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1482
View File
File diff suppressed because it is too large Load Diff
-159
View File
@@ -1,159 +0,0 @@
//! freemkv-info — Drive identification and compatibility checker.
//!
//! Sends standard SCSI INQUIRY and GET CONFIGURATION commands to an optical drive,
//! displays drive identity and compatibility status, and optionally outputs raw
//! response data for profile contribution.
//!
//! Usage:
//! freemkv-info /dev/sr0
//! freemkv-info /dev/sr0 --raw
//! freemkv-info /dev/sr0 --json
use std::env;
use std::path::Path;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("freemkv-info — Drive identification and compatibility checker");
eprintln!();
eprintln!("Usage: freemkv-info <device> [options]");
eprintln!();
eprintln!(" <device> Optical drive device (e.g. /dev/sr0)");
eprintln!(" --raw Output raw SCSI response hex (for profile contribution)");
eprintln!(" --json Output machine-readable JSON");
eprintln!(" --profiles Path to profiles directory (default: ./profiles)");
eprintln!();
eprintln!("Examples:");
eprintln!(" freemkv-info /dev/sr0");
eprintln!(" freemkv-info /dev/sr0 --raw > my_drive.txt");
process::exit(1);
}
let device = Path::new(&args[1]);
let raw_mode = args.iter().any(|a| a == "--raw");
let json_mode = args.iter().any(|a| a == "--json");
let profiles_dir = args.iter()
.position(|a| a == "--profiles")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or("profiles");
// Open SCSI transport
let mut transport = match libfreemkv::scsi::open(device) {
Ok(t) => t,
Err(e) => {
eprintln!("Error: Cannot open {}: {}", device.display(), e);
process::exit(1);
}
};
// INQUIRY
let inquiry = match libfreemkv::scsi::inquiry(transport.as_mut()) {
Ok(i) => i,
Err(e) => {
eprintln!("Error: INQUIRY failed: {}", e);
process::exit(1);
}
};
// GET CONFIGURATION feature 0x010C
let gc_010c = libfreemkv::scsi::get_config_010c(transport.as_mut()).ok();
if json_mode {
print_json(&inquiry, &gc_010c);
} else if raw_mode {
print_raw(&inquiry, &gc_010c);
} else {
print_human(&inquiry, &gc_010c, profiles_dir);
}
}
fn print_human(
inquiry: &libfreemkv::scsi::InquiryResult,
gc_010c: &Option<Vec<u8>>,
profiles_dir: &str,
) {
println!("freemkv-info v{}", env!("CARGO_PKG_VERSION"));
println!();
println!("Drive: {} {} {}", inquiry.vendor_id, inquiry.model, inquiry.firmware);
println!("INQUIRY: additional_length=0x{:02X} ({})",
inquiry.raw.get(4).unwrap_or(&0),
inquiry.raw.get(4).unwrap_or(&0));
if let Some(gc) = gc_010c {
let data_hex: String = gc.iter().map(|b| format!("{:02x}", b)).collect();
println!("Feature 0x010C: {}", data_hex);
} else {
println!("Feature 0x010C: not available");
}
// Try to match profile
if let Ok(profiles) = libfreemkv::profile::load_all(Path::new(profiles_dir)) {
let matched = profiles.iter().find(|p| {
p.drive_id.contains(&inquiry.vendor_id)
&& p.drive_id.contains(&inquiry.model)
});
println!();
match matched {
Some(p) => {
println!("Profile: FOUND ({})", p.chipset.name());
println!("Raw Read: Supported");
}
None => {
println!("Profile: NOT FOUND");
println!("Raw Read: Unknown — run with --raw and submit a profile request");
}
}
} else {
println!();
println!("Profile: No profiles directory found at '{}'", profiles_dir);
}
}
fn print_raw(
inquiry: &libfreemkv::scsi::InquiryResult,
gc_010c: &Option<Vec<u8>>,
) {
println!("# freemkv-info raw output");
println!("# Submit this file to https://github.com/freemkv/libfreemkv/issues");
println!();
println!("vendor: {}", inquiry.vendor_id);
println!("model: {}", inquiry.model);
println!("firmware: {}", inquiry.firmware);
println!();
// Full INQUIRY hex
println!("inquiry_hex: {}", hex_encode(&inquiry.raw));
println!("inquiry_length: {}", inquiry.raw.len());
// GET CONFIG 0x010C
if let Some(gc) = gc_010c {
println!("get_config_010c_hex: {}", hex_encode(gc));
println!("get_config_010c_length: {}", gc.len());
} else {
println!("get_config_010c_hex: ERROR");
}
}
fn print_json(
inquiry: &libfreemkv::scsi::InquiryResult,
gc_010c: &Option<Vec<u8>>,
) {
let json = serde_json::json!({
"vendor": inquiry.vendor_id,
"model": inquiry.model,
"firmware": inquiry.firmware,
"inquiry_hex": hex_encode(&inquiry.raw),
"inquiry_length": inquiry.raw.len(),
"get_config_010c_hex": gc_010c.as_ref().map(|g| hex_encode(g)),
});
println!("{}", serde_json::to_string_pretty(&json).unwrap());
}
fn hex_encode(data: &[u8]) -> String {
data.iter().map(|b| format!("{:02x}", b)).collect()
}
-99
View File
@@ -1,99 +0,0 @@
//! freemkv-test — Quick verification that raw disc access works.
//!
//! Enables raw read mode, calibrates speed, reads a few test sectors.
//! Use this to verify your drive and profile are working correctly.
//!
//! Usage:
//! freemkv-test /dev/sr0
//! freemkv-test /dev/sr0 --profiles ./profiles
use std::env;
use std::path::Path;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("freemkv-test — Verify raw disc access works");
eprintln!();
eprintln!("Usage: freemkv-test <device> [--profiles <dir>]");
process::exit(1);
}
let device = Path::new(&args[1]);
println!("freemkv-test v{}", env!("CARGO_PKG_VERSION"));
println!();
// Open drive session (uses bundled profiles)
print!("Opening {}... ", device.display());
let mut session = match libfreemkv::DriveSession::open(device) {
Ok(s) => { println!("OK"); s }
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
};
println!(" Drive ID: {}", session.profile.drive_id);
println!(" Chipset: {}", session.profile.chipset.name());
println!();
// Enable raw read mode
print!("Unlocking drive... ");
match session.unlock() {
Ok(()) => println!("OK"),
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
}
// Check status
print!("Checking status... ");
match session.status() {
Ok(status) => {
if status.unlocked {
println!("OK (active)");
} else {
println!("WARNING: drive reported as locked");
}
}
Err(e) => println!("SKIP ({})", e),
}
// Calibrate speed
print!("Calibrating speed... ");
match session.calibrate() {
Ok(()) => println!("OK"),
Err(e) => println!("SKIP ({})", e),
}
// Read test sectors
let test_lbas: &[u32] = &[0, 100, 1000, 10000];
let mut buf = vec![0u8; 2048];
let mut pass = 0;
let mut fail = 0;
for &lba in test_lbas {
print!("Reading sector {}... ", lba);
match session.read_sectors(lba, 1, &mut buf) {
Ok(n) if n == 2048 => {
let nonzero = buf.iter().filter(|&&b| b != 0).count();
println!("OK ({} bytes, {} non-zero)", n, nonzero);
pass += 1;
}
Ok(n) => {
println!("PARTIAL ({} bytes)", n);
fail += 1;
}
Err(e) => {
println!("FAILED: {}", e);
fail += 1;
}
}
}
println!();
if fail == 0 {
println!("All {} checks passed. Drive is fully functional.", pass);
} else {
println!("{} passed, {} failed.", pass, fail);
process::exit(1);
}
}
+25 -4
View File
@@ -183,11 +183,32 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
return Ok((Vec::new(), Vec::new()));
}
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
// stream_PID: 16 bits → ep_map[2..4]
// reserved: 10 bits ┐
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
// num_EP_coarse: 16 bits │ (10+4+16+18+32 = 80)
// num_EP_fine: 18 bits │
// EP_map_start_address: 32 bits ┘
if ep_map.len() < 16 { return Ok((Vec::new(), Vec::new())); }
let _stream_pid = u16::from_be_bytes([ep_map[2], ep_map[3]]);
// ep_map[4..6] = reserved + EP stream type
let num_coarse = u16::from_be_bytes([ep_map[6], ep_map[7]]) as usize;
let num_fine = u32::from_be_bytes([ep_map[8], ep_map[9], ep_map[10], ep_map[11]]) as usize;
let ep_map_offset = u32::from_be_bytes([ep_map[12], ep_map[13], ep_map[14], ep_map[15]]) as usize;
// Read 10 bytes (80 bits) from ep_map[4..14] for bit extraction
// Use two u64s since we need 80 bits
let hi = u64::from_be_bytes([ep_map[4], ep_map[5], ep_map[6], ep_map[7],
ep_map[8], ep_map[9], ep_map[10], ep_map[11]]);
let lo_bytes = [ep_map[12], ep_map[13]];
// Bit 0-9: reserved (10)
// Bit 10-13: EP_stream_type (4)
// Bit 14-29: num_coarse (16)
// Bit 30-47: num_fine (18)
// Bit 48-79: EP_map_start (32) — bits 48-63 in hi, bits 64-79 in lo
let num_coarse = ((hi >> 34) & 0xFFFF) as usize;
let num_fine = ((hi >> 16) & 0x3FFFF) as usize;
let ep_map_offset = (((hi & 0xFFFF) as u32) << 16)
| (u16::from_be_bytes(lo_bytes) as u32);
let ep_map_offset = ep_map_offset as usize;
// EP map for this stream starts at ep_map_offset relative to ep_map start
if ep_map_offset + 4 > ep_map.len() {
+919 -60
View File
File diff suppressed because it is too large Load Diff
+217 -75
View File
@@ -1,8 +1,13 @@
//! High-level drive session — the main API for consumers.
//! Drive session — open, identify, unlock, and read from optical drives.
//!
//! Opens a drive, identifies it via standard SCSI commands,
//! matches it against the profile database, and provides
//! raw disc access methods.
//! `DriveSession` is the entry point for all drive interaction. It handles
//! device identification, profile matching, platform-specific unlock, and
//! provides both raw sector reads and standard SCSI command execution.
//!
//! Two open modes:
//! - `open()` — identify + unlock. Ready for reading immediately.
//! - `open_no_unlock()` — identify only. Used for AACS authentication
//! which must happen before the drive enters raw mode.
use std::path::Path;
use crate::error::{Error, Result};
@@ -12,28 +17,50 @@ use crate::profile::{self, DriveProfile, Chipset};
use crate::platform::{Platform, DriveStatus};
use crate::platform::mt1959::Mt1959;
/// A complete drive session.
/// A drive session with identification, platform, and SCSI transport.
///
/// Handles: identify → match profile → create platform → execute commands.
/// Created via `DriveSession::open()` or `DriveSession::open_no_unlock()`.
/// All disc reading goes through this struct.
pub struct DriveSession {
scsi: Box<dyn ScsiTransport>,
platform: Box<dyn Platform>,
pub profile: DriveProfile,
pub drive_id: DriveId,
device_path: String,
}
impl DriveSession {
/// Open a drive, identify it, and find the matching profile.
/// Uses the bundled profile database — no external files needed.
/// Open a drive — identify, wait for disc, and unlock for raw reads.
///
/// This is the standard entry point. After `open()`, the drive is
/// ready for scanning and content reads.
pub fn open(device: &Path) -> Result<Self> {
let mut session = Self::open_no_unlock(device)?;
session.wait_ready()?;
let _ = session.init();
Ok(session)
}
/// Open a drive and immediately init for raw reads.
///
/// Use this when you need raw disc access without AACS (e.g. capture,
/// sector dumps). Skips AACS authentication — cannot be done after init.
pub fn open_unlocked(device: &Path) -> Result<Self> {
let mut session = Self::open_no_unlock(device)?;
session.wait_ready()?;
let _ = session.init();
Ok(session)
}
/// Open a drive — identify only, no wait, no unlock.
///
/// Low-level entry point. Caller is responsible for wait_ready()
/// and unlock() ordering.
pub fn open_no_unlock(device: &Path) -> Result<Self> {
let mut transport = crate::scsi::open(device)?;
let profiles = profile::load_bundled()?;
// Identify drive via standard SCSI commands
// SPC-4 §6.4 (INQUIRY) + MMC-6 §5.3.10 (Feature 010Ch)
let drive_id = DriveId::from_drive(transport.as_mut())?;
// Match drive to a profile by INQUIRY fields
let profile = profile::find_by_drive_id(&profiles, &drive_id)
.cloned()
.ok_or_else(|| Error::UnsupportedDrive {
@@ -42,98 +69,91 @@ impl DriveSession {
product_revision: drive_id.product_revision.trim().to_string(),
})?;
let platform: Box<dyn Platform> = match profile.chipset {
Chipset::MediaTek => {
Box::new(Mt1959::new(profile.clone()))
}
Chipset::Renesas => {
return Err(Error::UnsupportedDrive {
vendor_id: drive_id.vendor_id.trim().to_string(),
product_id: drive_id.product_id.trim().to_string(),
product_revision: "Renesas not yet implemented".to_string(),
});
}
};
let platform = create_platform(&profile, &drive_id)?;
Ok(DriveSession {
scsi: transport,
platform,
profile,
drive_id,
device_path: device.to_string_lossy().to_string(),
})
}
/// Open with an explicit profile (skip auto-detection).
/// Open with an explicit profile, skipping auto-detection.
pub fn open_with_profile(device: &Path, profile: DriveProfile) -> Result<Self> {
let mut transport = crate::scsi::open(device)?;
let drive_id = DriveId::from_drive(transport.as_mut())?;
let platform: Box<dyn Platform> = match profile.chipset {
Chipset::MediaTek => {
Box::new(Mt1959::new(profile.clone()))
}
Chipset::Renesas => {
return Err(Error::UnsupportedDrive {
vendor_id: drive_id.vendor_id.trim().to_string(),
product_id: drive_id.product_id.trim().to_string(),
product_revision: "Renesas not yet implemented".to_string(),
});
}
};
let platform = create_platform(&profile, &drive_id)?;
Ok(DriveSession {
scsi: transport,
platform,
profile,
drive_id,
device_path: device.to_string_lossy().to_string(),
})
}
/// Activate raw disc access mode.
pub fn unlock(&mut self) -> Result<()> {
self.platform.unlock(self.scsi.as_mut())
/// Wait for the drive to become ready (disc spun up).
/// Polls TEST UNIT READY up to 30 seconds.
pub fn wait_ready(&mut self) -> Result<()> {
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // TEST UNIT READY
for _ in 0..60 {
let mut buf = [0u8; 0];
if self.scsi.as_mut().execute(
&tur, crate::scsi::DataDirection::None, &mut buf, 5000
).is_ok() {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(Error::DeviceNotFound {
path: format!("{}: drive not ready after 30s", self.device_path),
})
}
/// Check if raw disc access mode is enabled.
/// Device path this session was opened on.
pub fn device_path(&self) -> &str {
&self.device_path
}
///
/// This is the ONLY entry point for activating raw disc access.
/// Handles the full x86 dispatch sequence internally:
/// unlock → [load_firmware if cold] × 6 → calibrate × 6 → registers
pub fn init(&mut self) -> Result<()> {
self.platform.init(self.scsi.as_mut())
}
/// Check if raw disc access mode is active.
pub fn is_unlocked(&self) -> bool {
self.platform.is_unlocked()
}
/// Read drive status and feature flags.
pub fn status(&mut self) -> Result<DriveStatus> {
self.platform.status(self.scsi.as_mut())
/// Called per zone change during content reads.
pub fn set_read_speed(&mut self, lba: u32) -> Result<()> {
self.platform.set_read_speed(self.scsi.as_mut(), lba)
}
/// 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.
pub fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.platform.read_sectors(self.scsi.as_mut(), lba, count, buf)
}
/// Generic probe command.
pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length)
}
/// Standard READ(10) — reads unencrypted sectors (UDF filesystem, etc).
/// Does not require unlock. Use read_sectors() for raw/encrypted content.
/// SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI).
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let cdb = [
0x28, 0x00, // READ(10), no flags
crate::scsi::SCSI_READ_10, 0x00,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00,
(count >> 8) as u8, count as u8,
0x00,
];
let result = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
Ok(result.bytes_transferred)
}
/// SCSI READ(10) for m2ts content — bulk reads with longer timeout.
pub fn read_content(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let cdb = [
crate::scsi::SCSI_READ_10, 0x00,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00,
(count >> 8) as u8, count as u8,
@@ -144,8 +164,130 @@ impl DriveSession {
Ok(result.bytes_transferred)
}
/// Send a raw SCSI CDB. Used by UDF reader and disc structure parsers.
pub fn scsi_execute(&mut self, cdb: &[u8], direction: crate::scsi::DataDirection, buf: &mut [u8], timeout_ms: u32) -> Result<crate::scsi::ScsiResult> {
/// Eject the disc tray.
///
/// Sends PREVENT ALLOW MEDIUM REMOVAL (allow) first to release any
/// locks, then START STOP UNIT with LoEj=1 to open the tray.
pub fn eject(&mut self) -> Result<()> {
// PREVENT ALLOW MEDIUM REMOVAL: allow removal
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
let mut buf = [0u8; 0];
let _ = self.scsi.as_mut().execute(&allow_cdb, crate::scsi::DataDirection::None, &mut buf, 5_000);
// START STOP UNIT: LoEj=1, Start=0
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
self.scsi.as_mut().execute(&eject_cdb, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
Ok(())
}
/// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
pub fn scsi_execute(
&mut self,
cdb: &[u8],
direction: crate::scsi::DataDirection,
buf: &mut [u8],
timeout_ms: u32,
) -> Result<crate::scsi::ScsiResult> {
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
}
}
/// Discover optical drives on the system.
///
/// Scans `/dev/sg0` through `/dev/sg15` (Linux SCSI Generic devices),
/// sends INQUIRY to each, and returns paths for optical drives (device type 5).
/// Always uses sg devices — sr devices have kernel-level speed management
/// that interferes with raw disc access.
///
/// Returns a list of (device_path, DriveId) for each found drive.
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
for i in 0..16 {
let path = format!("/dev/sg{}", i);
if !std::path::Path::new(&path).exists() {
continue;
}
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
// INQUIRY device type 5 = CD/DVD/BD
// We check by trying to match a profile — only optical drives have profiles
let profiles = match profile::load_bundled() {
Ok(p) => p,
Err(_) => continue,
};
if profile::find_by_drive_id(&profiles, &id).is_some() {
drives.push((path, id));
}
}
}
}
drives
}
/// Find the first optical drive on the system.
/// Returns the sg device path, or None if no drive found.
pub fn find_drive() -> Option<String> {
find_drives().into_iter().next().map(|(path, _)| path)
}
/// Resolve a device path to the correct sg device.
///
/// If the user passes `/dev/sr0`, maps it to the corresponding `/dev/sg*`.
/// If they pass `/dev/sg*`, validates it exists.
/// Returns `(resolved_path, warning)` where warning is set if the path was remapped.
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
// Already an sg device — use as-is
if path.contains("/sg") {
if !std::path::Path::new(path).exists() {
return Err(Error::DeviceNotFound { path: path.to_string() });
}
return Ok((path.to_string(), None));
}
// sr device — find the matching sg device by comparing INQUIRY data
if path.contains("/sr") {
// Open the sr device to get its identity
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
drop(sr_transport);
// Find matching sg device
for (sg_path, sg_id) in find_drives() {
if sg_id.vendor_id == sr_id.vendor_id
&& sg_id.product_id == sr_id.product_id
&& sg_id.serial_number == sr_id.serial_number
{
let warning = format!(
"{} is a block device (sr) — using {} (sg) for raw access",
path, sg_path
);
return Ok((sg_path, Some(warning)));
}
}
// No sg match found — fall back to sr with warning
let warning = format!(
"{} is a block device (sr) — no matching sg device found, performance may be limited",
path
);
return Ok((path.to_string(), Some(warning)));
}
// Unknown device type — use as-is
if !std::path::Path::new(path).exists() {
return Err(Error::DeviceNotFound { path: path.to_string() });
}
Ok((path.to_string(), None))
}
/// Create the platform-specific driver for a given chipset.
fn create_platform(profile: &DriveProfile, drive_id: &DriveId) -> Result<Box<dyn Platform>> {
match profile.chipset {
Chipset::MediaTek => Ok(Box::new(Mt1959::new(profile.clone()))),
Chipset::Renesas => Err(Error::UnsupportedDrive {
vendor_id: drive_id.vendor_id.trim().to_string(),
product_id: drive_id.product_id.trim().to_string(),
product_revision: "Renesas not yet implemented".to_string(),
}),
}
}
+108 -60
View File
@@ -1,97 +1,145 @@
/// libfreemkv error codes.
///
/// The library returns structured error codes with context data.
/// Applications are responsible for formatting user-facing messages.
/// This keeps the library locale-independent and testable.
//! Error types for libfreemkv.
//!
//! Every error carries a numeric code for programmatic handling.
//! No user-facing English text — applications format their own messages.
//! This keeps the library locale-independent and testable.
//!
//! # Error Code Ranges
//!
//! | Range | Category |
//! |-------|----------|
//! | E1xxx | Device errors |
//! | E2xxx | Profile errors |
//! | E3xxx | Unlock errors |
//! | E4xxx | SCSI errors |
//! | E5xxx | I/O errors |
//! | E6xxx | Disc format errors |
//! | E7xxx | AACS errors |
/// Error code table.
///
/// | Code | Name | Meaning |
/// |------|------|---------|
/// | 1000 | DeviceNotFound | Device path doesn't exist or can't be opened |
/// | 1001 | DevicePermission | Device exists but permission denied |
/// | 2000 | UnsupportedDrive | Drive not in profile database |
/// | 2001 | ProfileNotFound | Specific firmware version not in database |
/// | 2002 | ProfileParse | Profile database is malformed |
/// | 3000 | UnlockFailed | Drive rejected unlock command |
/// | 3001 | SignatureMismatch | Wrong signature returned by drive |
/// | 3002 | NotUnlocked | Raw read attempted before unlock |
/// | 3003 | NotCalibrated | Raw read attempted before calibrate |
/// | 4000 | ScsiError | SCSI command failed |
/// | 4001 | ScsiTimeout | SCSI command timed out |
/// | 5000 | IoError | OS-level I/O error |
// ── Error codes (single source of truth) ────────────────────────────────────
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
pub const E_DEVICE_PERMISSION: u16 = 1001;
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
pub const E_PROFILE_NOT_FOUND: u16 = 2001;
pub const E_PROFILE_PARSE: u16 = 2002;
pub const E_UNLOCK_FAILED: u16 = 3000;
pub const E_SIGNATURE_MISMATCH: u16 = 3001;
pub const E_NOT_UNLOCKED: u16 = 3002;
pub const E_NOT_CALIBRATED: u16 = 3003;
pub const E_SCSI_ERROR: u16 = 4000;
pub const E_SCSI_TIMEOUT: u16 = 4001;
pub const E_IO_ERROR: u16 = 5000;
pub const E_DISC_ERROR: u16 = 6000;
pub const E_AACS_ERROR: u16 = 7000;
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;
// ── Error enum ──────────────────────────────────────────────────────────────
/// Structured error with numeric code and context data.
#[derive(Debug)]
pub enum Error {
// 1xxx — Device errors
DeviceNotFound { path: String },
DevicePermission { path: String },
// 2xxx — Profile errors
UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String },
ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String },
ProfileParse { detail: String },
// 3xxx — Unlock errors
UnlockFailed { detail: String },
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
NotUnlocked,
NotCalibrated,
// 4xxx — SCSI errors
ScsiError { opcode: u8, status: u8, sense_key: u8 },
ScsiTimeout { opcode: u8 },
// 5xxx — I/O errors
IoError { source: std::io::Error },
// 6xxx — Disc format errors
DiscError { detail: String },
AacsError { detail: String },
KeydbConnect { host: String },
KeydbHttp { status: u16 },
KeydbInvalid,
KeydbWrite { path: String },
KeydbParse,
}
impl Error {
/// Numeric error code for programmatic handling.
/// Numeric error code.
pub fn code(&self) -> u16 {
match self {
Error::DeviceNotFound { .. } => 1000,
Error::DevicePermission { .. } => 1001,
Error::UnsupportedDrive { .. } => 2000,
Error::ProfileNotFound { .. } => 2001,
Error::ProfileParse { .. } => 2002,
Error::UnlockFailed { .. } => 3000,
Error::SignatureMismatch { .. } => 3001,
Error::NotUnlocked => 3002,
Error::NotCalibrated => 3003,
Error::ScsiError { .. } => 4000,
Error::ScsiTimeout { .. } => 4001,
Error::IoError { .. } => 5000,
Error::DiscError { .. } => 6000,
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND,
Error::ProfileParse { .. } => E_PROFILE_PARSE,
Error::UnlockFailed { .. } => E_UNLOCK_FAILED,
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
Error::NotUnlocked => E_NOT_UNLOCKED,
Error::NotCalibrated => E_NOT_CALIBRATED,
Error::ScsiError { .. } => E_SCSI_ERROR,
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
Error::IoError { .. } => E_IO_ERROR,
Error::DiscError { .. } => E_DISC_ERROR,
Error::AacsError { .. } => E_AACS_ERROR,
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,
}
}
}
/// Default Display — terse, for logs. Applications should format their own messages.
/// Display format: "E{code}: {context}" — terse, for logs.
/// Applications should format their own user-facing messages using code() and fields.
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::DeviceNotFound { path } => write!(f, "E1000: device not found: {path}"),
Error::DevicePermission { path } => write!(f, "E1001: permission denied: {path}"),
Error::DeviceNotFound { path } =>
write!(f, "E{}: {}", E_DEVICE_NOT_FOUND, path),
Error::DevicePermission { path } =>
write!(f, "E{}: {}", E_DEVICE_PERMISSION, path),
Error::UnsupportedDrive { vendor_id, product_id, product_revision } =>
write!(f, "E2000: unsupported drive: {} {} {}", vendor_id.trim(), product_id.trim(), product_revision.trim()),
write!(f, "E{}: {} {} {}", E_UNSUPPORTED_DRIVE,
vendor_id.trim(), product_id.trim(), product_revision.trim()),
Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } =>
write!(f, "E2001: no profile: {} {} {}", vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
Error::ProfileParse { detail } => write!(f, "E2002: profile parse: {detail}"),
Error::UnlockFailed { detail } => write!(f, "E3000: unlock failed: {detail}"),
write!(f, "E{}: {} {} {}", E_PROFILE_NOT_FOUND,
vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
Error::ProfileParse { detail } =>
write!(f, "E{}: {}", E_PROFILE_PARSE, detail),
Error::UnlockFailed { detail } =>
write!(f, "E{}: {}", E_UNLOCK_FAILED, detail),
Error::SignatureMismatch { expected, got } =>
write!(f, "E3001: signature mismatch: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
write!(f, "E{}: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
E_SIGNATURE_MISMATCH,
expected[0], expected[1], expected[2], expected[3],
got[0], got[1], got[2], got[3]),
Error::NotUnlocked => write!(f, "E3002: not unlocked"),
Error::NotCalibrated => write!(f, "E3003: not calibrated"),
Error::NotUnlocked =>
write!(f, "E{}", E_NOT_UNLOCKED),
Error::NotCalibrated =>
write!(f, "E{}", E_NOT_CALIBRATED),
Error::ScsiError { opcode, status, sense_key } =>
write!(f, "E4000: SCSI 0x{opcode:02x} failed: status=0x{status:02x} sense=0x{sense_key:02x}"),
Error::ScsiTimeout { opcode } => write!(f, "E4001: SCSI 0x{opcode:02x} timeout"),
Error::IoError { source } => write!(f, "E5000: {source}"),
Error::DiscError { detail } => write!(f, "E6000: disc: {detail}"),
write!(f, "E{}: opcode=0x{:02x} status=0x{:02x} sense=0x{:02x}",
E_SCSI_ERROR, opcode, status, sense_key),
Error::ScsiTimeout { opcode } =>
write!(f, "E{}: opcode=0x{:02x}", E_SCSI_TIMEOUT, opcode),
Error::IoError { source } =>
write!(f, "E{}: {}", E_IO_ERROR, source),
Error::DiscError { detail } =>
write!(f, "E{}: {}", E_DISC_ERROR, detail),
Error::AacsError { detail } =>
write!(f, "E{}: {}", E_AACS_ERROR, detail),
Error::KeydbConnect { host } =>
write!(f, "E{}: {}", E_KEYDB_CONNECT, host),
Error::KeydbHttp { status } =>
write!(f, "E{}: {}", E_KEYDB_HTTP, status),
Error::KeydbInvalid =>
write!(f, "E{}", E_KEYDB_INVALID),
Error::KeydbWrite { path } =>
write!(f, "E{}: {}", E_KEYDB_WRITE, path),
Error::KeydbParse =>
write!(f, "E{}", E_KEYDB_PARSE),
}
}
}
+24 -1
View File
@@ -39,6 +39,9 @@ pub struct DriveId {
/// Format: CCYYMMDDHHMI (12 ASCII characters)
pub firmware_date: String,
/// Drive serial number — GET CONFIGURATION Feature 0108h
pub serial_number: String,
/// Raw 96-byte INQUIRY response for additional parsing if needed.
pub raw_inquiry: Vec<u8>,
}
@@ -63,10 +66,29 @@ impl DriveId {
String::new()
};
Ok(Self::from_inquiry(&inquiry, &firmware_date))
// GET CONFIGURATION Feature 0108h — Serial Number
let mut gc_serial = vec![0u8; 256];
let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
let serial_number = if let Ok(r) = transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) {
if r.bytes_transferred > 12 {
String::from_utf8_lossy(&gc_serial[12..r.bytes_transferred])
.trim().to_string()
} else { String::new() }
} else { String::new() };
Ok(DriveId {
vendor_id: ascii_field(&inquiry, 8, 16),
product_id: ascii_field(&inquiry, 16, 32),
product_revision: ascii_field(&inquiry, 32, 36),
vendor_specific: ascii_field(&inquiry, 36, 43),
firmware_date,
serial_number,
raw_inquiry: inquiry.to_vec(),
})
}
/// Build identity from raw INQUIRY bytes and firmware date string.
/// Used by tests and when serial isn't available.
pub fn from_inquiry(inquiry: &[u8], firmware_date: &str) -> Self {
DriveId {
vendor_id: ascii_field(inquiry, 8, 16),
@@ -74,6 +96,7 @@ impl DriveId {
product_revision: ascii_field(inquiry, 32, 36),
vendor_specific: ascii_field(inquiry, 36, 43),
firmware_date: firmware_date.to_string(),
serial_number: String::new(),
raw_inquiry: inquiry.to_vec(),
}
}
+342
View File
@@ -0,0 +1,342 @@
//! BD-J JAR parser — extract audio/subtitle track labels from disc menus.
//!
//! Blu-ray discs with BD-J menus store the menu application as Java JAR files
//! in BDMV/JAR/. These contain .class files with string constants that label
//! audio and subtitle tracks.
//!
//! Multiple label formats exist across studios:
//! - Label format: "eng_MLP_", "fra_AudioStream3" (Warner UHD, e.g. Barbie, Dune)
//! - TextField format: "TextField,Audio1,English Dolby Atmos,..." (A24/Lionsgate, e.g. Civil War)
//!
//! Labels match to streams by STN index — the Nth audio label in the JAR
//! corresponds to the Nth audio stream in the MPLS STN table. This is defined
//! by the BD-J API where stream numbers = STN indices (1-based).
use std::io::Read;
/// Labels extracted from a BD-J JAR file.
#[derive(Debug, Default)]
pub struct JarLabels {
/// Audio track labels in STN order
pub audio: Vec<TrackLabel>,
/// Subtitle track labels in STN order
pub subtitle: Vec<TrackLabel>,
/// Playlist purpose labels (MAIN_FEATURE, FORCED_TRAILER, etc.)
pub playlists: Vec<String>,
}
/// A parsed track label from the JAR.
#[derive(Debug, Clone)]
pub struct TrackLabel {
/// Human-readable description (e.g. "English Dolby Atmos", "TrueHD", "Descriptive Audio (US)")
pub description: String,
/// ISO 639-2 language code if available (e.g. "eng", "fra")
pub language: String,
/// Codec hint if available (e.g. "MLP"=TrueHD, "AC3"=DD, "DTS")
pub codec_hint: String,
/// The raw string from the class file
pub raw: String,
}
/// Extract track labels from a JAR file (raw ZIP bytes).
///
/// Tries multiple format parsers. Returns None if no labels found.
pub fn extract_labels(jar_data: &[u8]) -> Option<JarLabels> {
let strings = extract_all_jar_strings(jar_data)?;
// Try each format parser in order
try_textfield_format(&strings)
.or_else(|| try_label_format(&strings))
.or_else(|| try_playlist_only(&strings))
}
// ── Format 1: TextField (A24/Lionsgate) ─────────────────────────────────────
//
// Pattern: "TextField,Audio{N},{description},..."
// "TextField,Subtitle{N},{description},..."
// Found in: Civil War, and similar A24/Lionsgate discs
fn try_textfield_format(strings: &[String]) -> Option<JarLabels> {
let mut audio: Vec<(u32, String, String)> = Vec::new(); // (index, description, raw)
let mut subtitle: Vec<(u32, String, String)> = Vec::new();
let mut playlists = Vec::new();
for s in strings {
if s.starts_with("TextField,Audio") {
// TextField,Audio{N},{description},...
let parts: Vec<&str> = s.splitn(4, ',').collect();
if parts.len() >= 3 {
let key = parts[1]; // "Audio1", "Audio2", etc.
let desc = parts[2].to_string();
if let Some(num_str) = key.strip_prefix("Audio") {
if let Ok(idx) = num_str.parse::<u32>() {
if !desc.is_empty() {
audio.push((idx, desc, s.clone()));
}
}
}
}
} else if s.starts_with("TextField,Subtitle") {
let parts: Vec<&str> = s.splitn(4, ',').collect();
if parts.len() >= 3 {
let key = parts[1];
let desc = parts[2].to_string();
if let Some(num_str) = key.strip_prefix("Subtitle") {
if let Ok(idx) = num_str.parse::<u32>() {
if !desc.eq_ignore_ascii_case("None") && !desc.is_empty() {
subtitle.push((idx, desc, s.clone()));
}
}
}
}
}
collect_playlist_label(s, &mut playlists);
}
if audio.is_empty() && subtitle.is_empty() {
return None;
}
// Sort by index to ensure STN order
audio.sort_by_key(|a| a.0);
subtitle.sort_by_key(|s| s.0);
Some(JarLabels {
audio: audio.into_iter().map(|(_, desc, raw)| TrackLabel {
description: desc, language: String::new(), codec_hint: String::new(), raw,
}).collect(),
subtitle: subtitle.into_iter().map(|(_, desc, raw)| TrackLabel {
description: desc, language: String::new(), codec_hint: String::new(), raw,
}).collect(),
playlists,
})
}
// ── Format 2: Label strings (Warner UHD) ────────────────────────────────────
//
// Pattern: "eng_MLP_", "fra_AudioStream3", "dan_PGStream4"
// Found in: Barbie, Dune Part Two, and similar Warner UHD discs
fn try_label_format(strings: &[String]) -> Option<JarLabels> {
let mut audio = Vec::new();
let mut subtitle = Vec::new();
let mut playlists = Vec::new();
for s in strings {
if let Some(label) = parse_label_string(s) {
if label.is_audio && !audio.iter().any(|a: &TrackLabel| a.raw == label.raw) {
audio.push(TrackLabel {
description: label.description, language: label.language,
codec_hint: label.codec_hint, raw: label.raw,
});
} else if label.is_subtitle && !subtitle.iter().any(|a: &TrackLabel| a.raw == label.raw) {
subtitle.push(TrackLabel {
description: label.description, language: label.language,
codec_hint: label.codec_hint, raw: label.raw,
});
}
}
collect_playlist_label(s, &mut playlists);
}
if audio.is_empty() && subtitle.is_empty() {
return None;
}
Some(JarLabels { audio, subtitle, playlists })
}
struct ParsedLabel {
description: String,
language: String,
codec_hint: String,
raw: String,
is_audio: bool,
is_subtitle: bool,
}
fn parse_label_string(s: &str) -> Option<ParsedLabel> {
let clean = s.trim_end_matches('_');
let parts: Vec<&str> = clean.splitn(3, '_').collect();
if parts.len() < 2 { return None; }
let language = parts[0];
if language.len() < 2 || language.len() > 3 || !language.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
let hint = parts[1];
let variant = if parts.len() > 2 { parts[2] } else { "" };
// Only set description when the JAR adds info beyond what MPLS provides.
// Codec (MLP, AC3, DTS) is already known from the stream. Don't make up labels.
let (description, codec_hint, is_audio, is_subtitle) = match hint {
"MLP" => (String::new(), "MLP".to_string(), true, false),
"AC3" => (String::new(), "AC3".to_string(), true, false),
"DTS" => (String::new(), "DTS".to_string(), true, false),
"LPCM" => (String::new(), "LPCM".to_string(), true, false),
"ADES" => {
// Descriptive Audio — real value-add, can't be derived from MPLS
let d = if variant.is_empty() { "Descriptive Audio".to_string() }
else { format!("Descriptive Audio ({})", variant) };
(d, "ADES".to_string(), true, false)
}
h if h.starts_with("AudioStream") => (String::new(), String::new(), true, false),
h if h.starts_with("PGStream") => (String::new(), String::new(), false, true),
_ => return None,
};
Some(ParsedLabel {
description, language: language.to_string(), codec_hint,
raw: s.to_string(), is_audio, is_subtitle,
})
}
// ── Format 3: Playlist-only ─────────────────────────────────────────────────
//
// Some JARs have no track labels but do have playlist purpose markers.
fn try_playlist_only(strings: &[String]) -> Option<JarLabels> {
let mut playlists = Vec::new();
for s in strings {
collect_playlist_label(s, &mut playlists);
}
if playlists.is_empty() { return None; }
Some(JarLabels { audio: Vec::new(), subtitle: Vec::new(), playlists })
}
// ── Common helpers ──────────────────────────────────────────────────────────
fn collect_playlist_label(s: &str, playlists: &mut Vec<String>) {
if matches!(s.as_ref(),
"MAIN_FEATURE" | "MAIN_FEATURE_INTRO" |
"FORCED_TRAILER" | "INTL_FORCED_TRAILER" |
"commentary_extras" | "extras"
) {
if !playlists.iter().any(|p| p == s) {
playlists.push(s.to_string());
}
}
}
/// Extract all UTF-8 string constants from all .class files in a JAR.
fn extract_all_jar_strings(jar_data: &[u8]) -> Option<Vec<String>> {
let cursor = std::io::Cursor::new(jar_data);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut all_strings = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i).ok()?;
if !file.name().ends_with(".class") { continue; }
let mut data = Vec::new();
file.read_to_end(&mut data).ok()?;
extract_class_strings(&data, &mut all_strings);
}
if all_strings.is_empty() { return None; }
Some(all_strings)
}
/// Extract UTF-8 string constants from a Java .class file's constant pool.
fn extract_class_strings(data: &[u8], out: &mut Vec<String>) {
if data.len() < 10 || &data[0..4] != &[0xCA, 0xFE, 0xBA, 0xBE] {
return;
}
let cp_count = ((data[8] as u16) << 8 | data[9] as u16) as usize;
let mut pos = 10;
let mut entry = 1;
while entry < cp_count && pos < data.len() {
let tag = data[pos];
pos += 1;
match tag {
// CONSTANT_Utf8
1 => {
if pos + 2 > data.len() { break; }
let len = ((data[pos] as usize) << 8) | data[pos + 1] as usize;
pos += 2;
if pos + len > data.len() { break; }
if let Ok(s) = std::str::from_utf8(&data[pos..pos + len]) {
if s.len() >= 3 && s.len() <= 500 {
out.push(s.to_string());
}
}
pos += len;
}
3 | 4 => { pos += 4; }
5 | 6 => { pos += 8; entry += 1; }
7 | 8 | 16 => { pos += 2; }
9 | 10 | 11 | 12 | 18 => { pos += 4; }
15 => { pos += 3; }
_ => { break; }
}
entry += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_textfield_audio() {
let strings = vec![
"TextField,Audio1,English Dolby Atmos,Font,296,763,275,25,left".to_string(),
"TextField,Audio2,English Descriptive Audio,Font,296,803,275,25,left".to_string(),
"TextField,Audio3,Spanish 5.1 Dolby Digital,Font,296,843,275,25,left".to_string(),
];
let labels = try_textfield_format(&strings).unwrap();
assert_eq!(labels.audio.len(), 3);
assert_eq!(labels.audio[0].description, "English Dolby Atmos");
assert_eq!(labels.audio[1].description, "English Descriptive Audio");
assert_eq!(labels.audio[2].description, "Spanish 5.1 Dolby Digital");
}
#[test]
fn test_textfield_subtitle_skips_none() {
let strings = vec![
"TextField,Subtitle0,None,Font,1312,843,275,25,left".to_string(),
"TextField,Subtitle1,English SDH,Font,1312,763,275,25,left".to_string(),
"TextField,Subtitle2,Spanish,Font,1312,803,275,25,left".to_string(),
];
let labels = try_textfield_format(&strings).unwrap();
assert_eq!(labels.subtitle.len(), 2);
assert_eq!(labels.subtitle[0].description, "English SDH");
assert_eq!(labels.subtitle[1].description, "Spanish");
}
#[test]
fn test_label_format_audio() {
let strings = vec![
"eng_MLP_".to_string(),
"eng_ADES_US_".to_string(),
"fra_AudioStream3".to_string(),
];
let labels = try_label_format(&strings).unwrap();
assert_eq!(labels.audio.len(), 3);
assert_eq!(labels.audio[0].description, ""); // MLP — codec already known from stream
assert_eq!(labels.audio[1].description, "Descriptive Audio (US)"); // ADES — real value-add
}
#[test]
fn test_label_format_subtitle() {
let strings = vec!["dan_PGStream4".to_string()];
let labels = try_label_format(&strings).unwrap();
assert_eq!(labels.subtitle.len(), 1);
}
#[test]
fn test_no_labels() {
let strings = vec!["java/lang/Object".to_string(), "toString".to_string()];
assert!(try_textfield_format(&strings).is_none());
assert!(try_label_format(&strings).is_none());
}
}
+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)
}
+159
View File
@@ -0,0 +1,159 @@
//! Criterion Collection — `streamproperties.xml` + `playbackconfig.xml`
//!
//! Clean structured XML with Content/Qualifier per stream and
//! stream number mapping via playbackconfig.
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
use std::collections::HashMap;
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "streamproperties.xml")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
let sp_text = std::str::from_utf8(&sp_data).ok()?;
let stream_infos = parse_stream_infos(sp_text);
if stream_infos.is_empty() { return None; }
// Stream number mapping from playbackconfig.xml
let mut stream_map: HashMap<String, u16> = HashMap::new();
if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") {
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
parse_playback_config(pc_text, &mut stream_map);
}
}
let mut labels = Vec::new();
let mut audio_idx: u16 = 1;
let mut sub_idx: u16 = 1;
for info in &stream_infos {
let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| {
match info.stream_type {
StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n }
StreamLabelType::Subtitle => { let n = sub_idx; sub_idx += 1; n }
}
});
labels.push(StreamLabel {
stream_number: stream_num,
stream_type: info.stream_type,
language: info.language.clone(),
name: String::new(),
purpose: info.purpose,
qualifier: info.qualifier,
codec_hint: String::new(),
variant: info.variant.clone(),
});
}
if labels.is_empty() { return None; }
Some(labels)
}
struct StreamInfo {
id: String,
stream_type: StreamLabelType,
language: String,
variant: String,
purpose: LabelPurpose,
qualifier: LabelQualifier,
}
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
let mut infos = Vec::new();
let mut pos = 0;
while pos < xml.len() {
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
(p + pos, StreamLabelType::Audio)
} else if let Some(p) = xml[pos..].find("<SubtitleStreamInfos>") {
(p + pos, StreamLabelType::Subtitle)
} else {
break;
};
let end_tag = match stream_type {
StreamLabelType::Audio => "</AudioStreamInfos>",
StreamLabelType::Subtitle => "</SubtitleStreamInfos>",
};
let block_end = match xml[tag..].find(end_tag) {
Some(p) => tag + p + end_tag.len(),
None => break,
};
let block = &xml[tag..block_end];
let id = extract_tag(block, "ID").unwrap_or_default();
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
let content = extract_tag(block, "Content").unwrap_or_default();
let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default();
let (language, variant) = if lang_id.contains('_') {
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
(parts[0].to_lowercase(), parts[1].to_string())
} else {
(lang_id.to_lowercase(), String::new())
};
let purpose = match content.as_str() {
"COMMENTARY" => LabelPurpose::Commentary,
_ => LabelPurpose::Normal,
};
let qualifier = match qualifier_str.as_str() {
"SDH" => LabelQualifier::Sdh,
"DS" => LabelQualifier::DescriptiveService,
_ => LabelQualifier::None,
};
infos.push(StreamInfo { id, stream_type, language, variant, purpose, qualifier });
pos = block_end;
}
infos
}
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
let mut pos = 0;
while pos < xml.len() {
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") {
Some(p + pos)
} else if let Some(p) = xml[pos..].find("<SubtitlesStreams>") {
Some(p + pos)
} else {
None
};
let tag_start = match tag_start {
Some(p) => p,
None => break,
};
let block_end = xml[tag_start..].find("</AudioStreams>")
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
.map(|p| tag_start + p + 20)
.unwrap_or(xml.len());
let block = &xml[tag_start..block_end];
if let (Some(stream_id_str), Some(info_id)) = (extract_tag(block, "StreamID"), extract_tag(block, "StreamInfo_ID")) {
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
map.insert(info_id, stream_num);
}
}
pos = block_end;
}
}
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].trim().to_string())
}
+208
View File
@@ -0,0 +1,208 @@
//! Warner CTRM — `menu_base.prop` and/or `language_streams.txt`
//!
//! Two sub-formats from the same framework. A disc may have one or both.
//! When both exist, language_streams.txt provides structured types while
//! menu_base.prop provides stream number → button name mapping.
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
use std::collections::HashMap;
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "menu_base.prop")
|| super::jar_file_exists(udf, "language_streams.txt")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
// Try language_streams.txt first (richer structured data)
let ls_labels = parse_language_streams(session, udf);
// Try menu_base.prop (stream numbers + key names)
let mb_labels = parse_menu_base(session, udf);
// If we have both, merge: language_streams for structure, menu_base for names
match (ls_labels, mb_labels) {
(Some(ls), Some(mb)) => Some(merge(ls, mb)),
(Some(ls), None) => Some(ls),
(None, Some(mb)) => Some(mb),
(None, None) => None,
}
}
fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
// language_streams has better type/purpose data, menu_base has button names
// Match by stream number + type, take name from menu_base
let mut result = ls;
for label in &mut result {
if let Some(mb_match) = mb.iter().find(|m|
m.stream_type == label.stream_type && m.stream_number == label.stream_number
) {
if label.name.is_empty() && !mb_match.name.is_empty() {
label.name = mb_match.name.clone();
}
}
}
result
}
// ── language_streams.txt parser ────────────────────────────────────────────
fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "language_streams.txt")?;
let text = std::str::from_utf8(&data).ok()?;
let mut labels = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') { continue; }
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() < 4 { continue; }
let type_str = parts[1];
let stream_num: u16 = match parts[2].parse() {
Ok(n) => n,
Err(_) => continue,
};
let language = parts[3].to_string();
let variant = if parts.len() > 4 { parts[4].to_string() } else { String::new() };
let (stream_type, purpose, qualifier) = match type_str {
"audio_production" => (StreamLabelType::Audio, LabelPurpose::Normal, LabelQualifier::None),
"audio_commentary" => (StreamLabelType::Audio, LabelPurpose::Commentary, LabelQualifier::None),
"audio_ime" => (StreamLabelType::Audio, LabelPurpose::Ime, LabelQualifier::None),
"subtitle_production" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
"subtitle_commentary" => (StreamLabelType::Subtitle, LabelPurpose::Commentary, LabelQualifier::None),
"subtitle_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::Forced),
"subtitle_dual" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
"subtitle_bonus" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
"subtitle_ime" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::None),
"subtitle_ime_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::Forced),
_ => continue,
};
// Classify variant code
let mut codec_hint = String::new();
let mut variant_code = String::new();
let mut final_purpose = purpose;
if !variant.is_empty() {
match variant.as_str() {
// Codec variants — use shared label vocab
"atmos" | "MLP" | "AC3" | "DTS" | "DDL" => {
codec_hint = vocab::codec(&variant).to_string();
}
// Purpose variants
"eda" => final_purpose = LabelPurpose::Descriptive,
// Dialect variants — pass through raw code from disc
"csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => {
variant_code = variant.clone();
}
// Unknown — store as-is in codec_hint
_ => codec_hint = variant.clone(),
}
}
labels.push(StreamLabel {
stream_number: stream_num,
stream_type,
language,
name: String::new(),
purpose: final_purpose,
qualifier,
codec_hint,
variant: variant_code,
});
}
if labels.is_empty() { return None; }
Some(labels)
}
// ── menu_base.prop parser ──────────────────────────────────────────────────
fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "menu_base.prop")?;
let text = std::str::from_utf8(&data).ok()?;
// Parse key=value, group by prefix
let mut entries: HashMap<String, HashMap<String, String>> = HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') { continue; }
let eq_pos = match line.find('=') {
Some(p) => p,
None => continue,
};
let full_key = &line[..eq_pos];
let value = &line[eq_pos + 1..];
if let Some(dot_pos) = full_key.rfind('.') {
let prefix = full_key[..dot_pos].to_string();
let key = full_key[dot_pos + 1..].to_string();
entries.entry(prefix).or_default().insert(key, value.to_string());
}
}
let mut labels = Vec::new();
for (prefix, props) in &entries {
// Audio: has "streamNumber" or "audioStream" and audio-related class
let is_audio = props.get("class").map_or(false, |c| c.contains("AudioButton"))
|| prefix.starts_with("audio_");
let is_subtitle = props.get("class").map_or(false, |c| c.contains("SubtitleButton"))
|| prefix.starts_with("subtitle_");
let stream_num_str = props.get("streamNumber")
.or_else(|| props.get("audioStream"))
.or_else(|| props.get("subtitleStream"));
let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) {
Some(n) if n > 0 => n,
_ => continue,
};
if !is_audio && !is_subtitle { continue; }
let name = props.get("name").cloned().unwrap_or_default();
let name_lower = name.to_lowercase();
let purpose = if name_lower.contains("comment") || prefix.contains("comm") {
LabelPurpose::Commentary
} else {
LabelPurpose::Normal
};
let qualifier = if is_subtitle && name_lower.contains("sdh") {
LabelQualifier::Sdh
} else {
LabelQualifier::None
};
let stream_type = if is_audio { StreamLabelType::Audio } else { StreamLabelType::Subtitle };
// Try to extract language from audioLanguage/subtitleLanguage prop
let language = props.get("audioLanguage")
.or_else(|| props.get("subtitleLanguage"))
.cloned()
.unwrap_or_default();
labels.push(StreamLabel {
stream_number: stream_num,
stream_type,
language,
name,
purpose,
qualifier,
codec_hint: String::new(),
variant: String::new(),
});
}
if labels.is_empty() { return None; }
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
Some(labels)
}
+175
View File
@@ -0,0 +1,175 @@
//! Stream label extraction from BD-J disc files.
//!
//! Each parser module represents one BD-J authoring framework.
//! To add a new format:
//! 1. Create `src/labels/myformat.rs`
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
mod paramount;
mod criterion;
mod pixelogic;
mod ctrm;
pub mod vocab;
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use crate::disc::{Title, Stream};
/// A stream label extracted from disc config files.
#[derive(Debug, Clone)]
pub struct StreamLabel {
/// STN index (1-based)
pub stream_number: u16,
/// Audio or Subtitle
pub stream_type: StreamLabelType,
/// ISO 639-2 language code
pub language: String,
/// Display name (e.g. "Commentary", "Descriptive Audio")
pub name: String,
/// Stream purpose
pub purpose: LabelPurpose,
/// Additional qualifier
pub qualifier: LabelQualifier,
/// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos")
pub codec_hint: String,
/// Regional variant (e.g. "US", "UK", "Castilian", "Canadian")
pub variant: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StreamLabelType {
Audio,
Subtitle,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LabelPurpose {
Normal,
Commentary,
Descriptive,
Score,
Ime,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LabelQualifier {
None,
Sdh,
DescriptiveService,
Forced,
}
// ── Parser registry ────────────────────────────────────────────────────────
//
// Each entry: (name, detect_fn, parse_fn)
// Order = priority. First match wins. Highest quality output first.
type DetectFn = fn(&UdfFs) -> bool;
type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>;
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
("paramount", paramount::detect, paramount::parse),
("criterion", criterion::detect, criterion::parse),
("pixelogic", pixelogic::detect, pixelogic::parse),
("ctrm", ctrm::detect, ctrm::parse),
// ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser
];
/// Search disc for config files, extract labels, apply to streams.
/// This is 100% optional — if anything fails, streams are untouched.
pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
extract(session, udf)
})).unwrap_or_default();
if labels.is_empty() { return; }
for title in titles.iter_mut() {
let mut audio_idx: u16 = 0;
let mut sub_idx: u16 = 0;
for stream in &mut title.streams {
match stream {
Stream::Audio(a) => {
audio_idx += 1;
if let Some(label) = labels.iter().find(|l|
l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
) {
let mut parts = Vec::new();
match label.purpose {
LabelPurpose::Commentary => parts.push("Commentary".to_string()),
LabelPurpose::Descriptive => parts.push("Descriptive Audio".to_string()),
LabelPurpose::Score => parts.push("Score".to_string()),
LabelPurpose::Ime => parts.push("IME".to_string()),
LabelPurpose::Normal => {}
}
if !label.variant.is_empty() {
parts.push(format!("({})", label.variant));
}
if !label.codec_hint.is_empty() {
parts.push(label.codec_hint.clone());
}
if !parts.is_empty() {
a.label = parts.join(" ");
} else if !label.name.is_empty() {
a.label = label.name.clone();
}
}
}
Stream::Subtitle(s) => {
sub_idx += 1;
if let Some(label) = labels.iter().find(|l|
l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
) {
if label.qualifier == LabelQualifier::Forced {
s.forced = true;
}
}
}
_ => {}
}
}
}
}
fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
for (_name, detect, parse) in PARSERS {
if detect(udf) {
if let Some(labels) = parse(session, udf) {
return labels;
}
}
}
Vec::new()
}
// ── Shared helpers ─────────────────────────────────────────────────────────
/// Check if a file exists in any BDMV/JAR subdirectory.
pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool {
find_jar_file(udf, filename).is_some()
}
/// Find a file in any BDMV/JAR subdirectory, return its path.
pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
let jar_dir = udf.find_dir("/BDMV/JAR")?;
for entry in &jar_dir.entries {
if entry.is_dir {
let path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
// Check if file exists in this subdirectory
for child in &entry.entries {
if !child.is_dir && child.name.eq_ignore_ascii_case(filename) {
return Some(path);
}
}
}
}
None
}
/// Read a file from any BDMV/JAR subdirectory by filename.
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
let path = find_jar_file(udf, filename)?;
udf.read_file(session, &path).ok().filter(|d| !d.is_empty())
}
+142
View File
@@ -0,0 +1,142 @@
//! Paramount/onQ — `playlists.xml`
//!
//! Richest structured format. Complete language lists with forced flags
//! and commentary indices per playlist, all in XML attributes.
//!
//! ```xml
//! <playlist name="Feature" id="00222"
//! aud="eng,deu,spa,spa,fra"
//! sub="eng,eng,zho,ces,dan"
//! forced_sub="0,0,0,1,0"
//! aud_com1_idx="10"
//! sub_com1_idx="23,24,25" />
//! ```
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "playlists.xml")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "playlists.xml")?;
let text = std::str::from_utf8(&data).ok()?;
// Find the feature playlist — longest duration or name="Feature"
let feature = find_feature_playlist(text)?;
let mut labels = Vec::new();
// Parse audio streams
if let Some(aud) = extract_attr(&feature, "aud") {
let com_idx = extract_attr(&feature, "aud_com1_idx")
.and_then(|s| s.parse::<usize>().ok());
for (i, lang) in aud.split(',').enumerate() {
let lang = lang.trim();
if lang.is_empty() { continue; }
let purpose = if com_idx == Some(i) {
LabelPurpose::Commentary
} else {
LabelPurpose::Normal
};
labels.push(StreamLabel {
stream_number: (i + 1) as u16,
stream_type: StreamLabelType::Audio,
language: lang.to_string(),
name: String::new(),
purpose,
qualifier: LabelQualifier::None,
codec_hint: String::new(),
variant: String::new(),
});
}
}
// Parse subtitle streams
if let Some(sub) = extract_attr(&feature, "sub") {
let forced: Vec<bool> = extract_attr(&feature, "forced_sub")
.map(|s| s.split(',').map(|f| f.trim() == "1").collect())
.unwrap_or_default();
let com_indices: Vec<usize> = extract_attr(&feature, "sub_com1_idx")
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
.unwrap_or_default();
for (i, lang) in sub.split(',').enumerate() {
let lang = lang.trim();
if lang.is_empty() { continue; }
let purpose = if com_indices.contains(&i) {
LabelPurpose::Commentary
} else {
LabelPurpose::Normal
};
let qualifier = if forced.get(i).copied().unwrap_or(false) {
LabelQualifier::Forced
} else {
LabelQualifier::None
};
labels.push(StreamLabel {
stream_number: (i + 1) as u16,
stream_type: StreamLabelType::Subtitle,
language: lang.to_string(),
name: String::new(),
purpose,
qualifier,
codec_hint: String::new(),
variant: String::new(),
});
}
}
if labels.is_empty() { return None; }
Some(labels)
}
/// Find the feature playlist element (the one with the most audio tracks).
fn find_feature_playlist(xml: &str) -> Option<String> {
let mut best: Option<String> = None;
let mut best_aud_count = 0;
let mut pos = 0;
while let Some(start) = xml[pos..].find("<playlist ") {
let abs_start = pos + start;
let end = match xml[abs_start..].find("/>") {
Some(p) => abs_start + p + 2,
None => break,
};
let element = &xml[abs_start..end];
// Prefer name="Feature" explicitly
if let Some(name) = extract_attr(element, "name") {
if name.eq_ignore_ascii_case("Feature") {
return Some(element.to_string());
}
}
// Otherwise pick the one with the most audio streams
if let Some(aud) = extract_attr(element, "aud") {
let count = aud.split(',').count();
if count > best_aud_count {
best_aud_count = count;
best = Some(element.to_string());
}
}
pos = end;
}
best
}
/// Extract an XML attribute value from an element string.
fn extract_attr<'a>(element: &'a str, name: &str) -> Option<String> {
let needle = format!("{}=\"", name);
let start = element.find(&needle)? + needle.len();
let end = element[start..].find('"')? + start;
Some(element[start..end].to_string())
}
+134
View File
@@ -0,0 +1,134 @@
//! Pixelogic — `bluray_project.bin`
//!
//! Binary file with embedded UTF-8 token strings in STN order per
//! playlist section. Most common format (5/10 test discs).
//!
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
/// Known audio codec tokens
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
/// Known region tokens
const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"];
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "bluray_project.bin")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
let strings = extract_strings(&data);
let mut labels = Vec::new();
let mut in_feature = false;
let mut audio_num: u16 = 0;
let mut sub_num: u16 = 0;
for s in &strings {
// Detect feature section start
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
if in_feature { break; }
in_feature = true;
audio_num = 0;
sub_num = 0;
continue;
}
// Detect section end
if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
break;
}
if !in_feature { continue; }
if let Some(label) = parse_token(s) {
match label.stream_type {
StreamLabelType::Audio => {
audio_num += 1;
labels.push(StreamLabel { stream_number: audio_num, ..label });
}
StreamLabelType::Subtitle => {
sub_num += 1;
labels.push(StreamLabel { stream_number: sub_num, ..label });
}
}
}
}
if labels.is_empty() { return None; }
Some(labels)
}
fn parse_token(s: &str) -> Option<StreamLabel> {
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
let parts: Vec<&str> = clean.split('_').collect();
if parts.len() < 2 { return None; }
let lang = parts[0];
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
let mut codec = String::new();
let mut purpose = LabelPurpose::Normal;
let mut qualifier = LabelQualifier::None;
let mut variant = String::new();
let mut is_subtitle = false;
let mut is_audio = false;
for &part in &parts[1..] {
if part.is_empty() { continue; }
if AUDIO_CODECS.contains(&part) { codec = vocab::codec(part).to_string(); is_audio = true; }
else if part == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; }
else if part == "ACOM" { purpose = LabelPurpose::Commentary; is_audio = true; }
else if part == "ADLG" { is_audio = true; }
else if part == "ATRI" { is_audio = true; }
else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; }
else if part == "SDLG" { is_subtitle = true; }
else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; }
else if part == "STRI" { is_subtitle = true; }
else if part == "TXT" { is_subtitle = true; }
else if part == "FOR" { qualifier = LabelQualifier::Forced; }
else if REGIONS.contains(&part) { variant = part.to_string(); }
else if part.starts_with("PGStream") { is_subtitle = true; }
else { return None; }
}
if !is_audio && !is_subtitle { return None; }
let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio };
Some(StreamLabel {
stream_number: 0,
stream_type,
language: lang.to_string(),
name: String::new(),
purpose,
qualifier,
codec_hint: codec,
variant,
})
}
fn extract_strings(data: &[u8]) -> Vec<String> {
let mut strings = Vec::new();
let mut current = String::new();
for &b in data {
if b >= 0x20 && b < 0x7f {
current.push(b as char);
} else {
if current.len() > 3 {
strings.push(current.clone());
}
current.clear();
}
}
if current.len() > 3 {
strings.push(current);
}
strings
}
+27
View File
@@ -0,0 +1,27 @@
//! Shared label vocabulary — values we are 100% confident about.
//!
//! Labels come from BD-J authoring tool files (bluray_project.bin,
//! playlists.xml, menu_base.prop, etc.) — NOT from BD spec fields.
//! This is NOT for MPLS/CLPI/STN data. Those follow the BD spec directly.
//!
//! Rules:
//! - Only map values we are 100% certain about (published codec names).
//! - Unknown codes (csp, eda, cf, etc.) pass through raw from disc.
//! - The app/CLI handles display text, not the lib.
/// Map a codec identifier found in label data to its display name.
///
/// These are well-known codec identifiers used across multiple BD-J
/// authoring tools. NOT BD spec STN codec IDs — those are decoded
/// separately in mpls.rs.
pub fn codec(code: &str) -> &str {
match code {
"MLP" => "TrueHD",
"AC3" | "AC" => "Dolby Digital",
"DTS" => "DTS",
"DDL" => "Dolby Digital Plus",
"WAV" => "PCM",
"atmos" => "Dolby Atmos",
_ => code,
}
}
+65 -27
View File
@@ -1,38 +1,71 @@
//! libfreemkv — Open source raw disc access for optical drives.
//! libfreemkv — Open source optical drive library for 4K UHD / Blu-ray / DVD.
//!
//! Provides SCSI/MMC commands to enable raw reading mode on compatible
//! Blu-ray drives, allowing direct sector access for disc archival
//! and backup purposes.
//!
//! # Architecture
//!
//! The library is data-driven. Drive-specific SCSI command sequences
//! are stored in profile files, not in code. Adding support for a new
//! drive requires only a profile contribution — no rebuild needed.
//!
//! ```text
//! DriveSession (high-level API)
//! ├── Platform trait (per-chipset unlock logic)
//! ├── DriveProfile (per-drive data from JSON profiles)
//! └── ScsiTransport (SG_IO on Linux, IOKit on macOS)
//! ```
//! Handles drive access, disc structure parsing, AACS decryption, and raw
//! sector reading. 206 bundled drive profiles. No external files needed.
//!
//! # Quick Start
//!
//! ```no_run
//! use libfreemkv::DriveSession;
//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive};
//! use std::path::Path;
//!
//! let mut session = DriveSession::open(
//! Path::new("/dev/sr0"),
//! ).unwrap();
//! let device = find_drive().expect("no optical drive found");
//! let mut session = DriveSession::open(Path::new(&device)).unwrap();
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
//!
//! session.unlock().unwrap();
//! session.calibrate().unwrap();
//! for title in &disc.titles {
//! println!("{} — {} streams", title.duration_display(), title.streams.len());
//! }
//!
//! let mut buf = vec![0u8; 2048];
//! let n = session.read_sectors(0, 1, &mut buf).unwrap();
//! // Read content (decrypted automatically if AACS keys available)
//! let mut reader = disc.open_title(&mut session, 0).unwrap();
//! while let Some(unit) = reader.read_unit().unwrap() {
//! // 6144 bytes of decrypted content per unit
//! }
//! ```
//!
//! # Architecture
//!
//! ```text
//! DriveSession — open, identify, unlock, read sectors
//! ├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
//! ├── DriveProfile — per-drive unlock parameters (206 bundled)
//! ├── DriveId — INQUIRY + GET_CONFIG identification
//! └── Platform
//! └── Mt1959 — MediaTek unlock/read (Renesas planned)
//!
//! Disc — scan titles, streams, AACS state
//! ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
//! ├── MPLS parser — playlists → titles + clips + STN streams
//! ├── CLPI parser — clip info → EP map → sector extents
//! ├── JAR parser — BD-J audio track labels
//! └── AACS — encryption: key resolution + content decrypt
//! ├── aacs — KEYDB, VUK, MKB, unit decrypt
//! └── handshake — SCSI auth, ECDH, bus key
//! ```
//!
//! # AACS Encryption
//!
//! Disc scanning automatically detects and handles AACS encryption.
//! If a KEYDB.cfg is available (via `ScanOptions` or standard paths),
//! the library resolves keys and decrypts content transparently.
//!
//! Supports AACS 1.0 (Blu-ray) and AACS 2.0 (UHD, with fallback).
//!
//! # Error Codes
//!
//! All errors are structured with numeric codes. No user-facing English
//! text — applications format their own messages.
//!
//! | Range | Category |
//! |-------|----------|
//! | E1xxx | Device errors (not found, permission) |
//! | E2xxx | Profile errors (unsupported drive) |
//! | E3xxx | Unlock errors (failed, signature) |
//! | E4xxx | SCSI errors (command failed, timeout) |
//! | E5xxx | I/O errors |
//! | E6xxx | Disc format errors |
//! | E7xxx | AACS errors |
pub mod error;
pub mod scsi;
@@ -45,12 +78,17 @@ pub mod udf;
pub mod mpls;
pub mod clpi;
pub mod disc;
pub mod aacs;
pub mod labels;
pub mod keydb;
pub use error::{Error, Result};
pub use drive::DriveSession;
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 scsi::ScsiTransport;
pub use speed::DriveSpeed;
pub use disc::{Disc, Title, Stream, StreamKind, Codec, HdrFormat, ColorSpace, Extent};
pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
Codec, HdrFormat, ColorSpace,
Extent, ContentReader, AacsState, KeySource, ScanOptions};
+203 -44
View File
@@ -15,10 +15,8 @@ pub struct Playlist {
pub version: String,
/// Play items in playback order
pub play_items: Vec<PlayItem>,
/// Number of video streams
pub video_stream_count: u16,
/// Number of audio streams
pub audio_stream_count: u16,
/// Streams from the first play item's STN table
pub streams: Vec<StreamEntry>,
}
/// A play item — one clip reference with in/out times.
@@ -34,73 +32,155 @@ pub struct PlayItem {
pub connection_condition: u8,
}
/// A stream entry from the STN table.
#[derive(Debug, Clone)]
pub struct StreamEntry {
/// Stream category: 1=video, 2=audio, 3=PG subtitle, 4=IG, 5=secondary audio, 6=secondary video, 7=DV EL
pub stream_type: u8,
/// MPEG-TS PID
pub pid: u16,
/// Coding type (0x24=HEVC, 0x1B=H264, 0x83=TrueHD, etc.)
pub coding_type: u8,
/// Video format (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p)
pub video_format: u8,
/// Video frame rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94)
pub video_rate: u8,
/// Audio channel layout (1=mono, 3=stereo, 6=5.1, 12=7.1)
pub audio_format: u8,
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz)
pub audio_rate: u8,
/// ISO 639-2 language code (e.g. "eng")
pub language: String,
/// HDR dynamic range (0=SDR, 1=HDR10, 2=Dolby Vision)
pub dynamic_range: u8,
/// Color space (0=unknown, 1=BT.709, 2=BT.2020)
pub color_space: u8,
/// Whether this is a secondary stream (commentary, PiP, DV EL)
pub secondary: bool,
}
/// Parse an MPLS file from raw bytes.
pub fn parse(data: &[u8]) -> Result<Playlist> {
if data.len() < 40 {
return Err(Error::DiscError { detail: "MPLS too short".into() });
}
// Header: "MPLS" + version (4 bytes ASCII)
if &data[0..4] != b"MPLS" {
return Err(Error::DiscError { detail: "not an MPLS file".into() });
}
let version = String::from_utf8_lossy(&data[4..8]).to_string();
// Offsets table at bytes 8-19
let version = String::from_utf8_lossy(&data[4..8]).to_string();
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
let _playlist_mark_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
if playlist_start + 10 > data.len() {
return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() });
}
// PlayList section
let pl = &data[playlist_start..];
let _pl_length = u32::from_be_bytes([pl[0], pl[1], pl[2], pl[3]]) as usize;
// pl[4..6] reserved
let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize;
let _num_sub_paths = u16::from_be_bytes([pl[8], pl[9]]) as usize;
let mut play_items = Vec::with_capacity(num_play_items);
let mut pos = 10; // start of first play item
let mut video_streams: u16 = 0;
let mut audio_streams: u16 = 0;
for _ in 0..num_play_items {
if playlist_start + pos + 2 > data.len() {
break;
}
let mut streams = Vec::new();
let mut pos = 10;
for item_idx in 0..num_play_items {
if pos + 2 > pl.len() { break; }
let item_length = u16::from_be_bytes([pl[pos], pl[pos + 1]]) as usize;
if playlist_start + pos + item_length + 2 > data.len() {
break;
}
if pos + 2 + item_length > pl.len() { break; }
let item = &pl[pos + 2..pos + 2 + item_length];
if item.len() < 20 { pos += 2 + item_length; continue; }
// Clip ID: 5 bytes ASCII at offset 0 (e.g. "00001")
let clip_id = String::from_utf8_lossy(&item[0..5]).to_string();
// item[5..9] = codec ID ("M2TS")
// item[9] = connection condition (bits)
let connection_condition = item[9] & 0x0F;
// item[10] = ref to STC_id
// item[12..16] = IN_time
let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]);
// item[16..20] = OUT_time
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
// STN table follows at offset 20 within the play item
if item.len() > 22 {
let stn_length = u16::from_be_bytes([item[20], item[21]]) as usize;
if stn_length > 4 && item.len() > 24 {
// Number of primary video/audio entries
let n_video = item[24] as u16;
let n_audio = item[25] as u16;
if video_streams == 0 {
video_streams = n_video;
audio_streams = n_audio;
}
// Parse STN table from the first play item
// PlayItem layout after out_time:
// [20:28] UO_mask_table (8 bytes)
// [28] misc flags (1 byte)
// [29] still_mode (1 byte)
// [30:32] still_time (2 bytes)
// [32:] STN_table
const STN_OFFSET: usize = 32;
if item_idx == 0 && item.len() > STN_OFFSET + 16 {
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
let n_video = item[STN_OFFSET + 4] as usize;
let n_audio = item[STN_OFFSET + 5] as usize;
let n_pg = item[STN_OFFSET + 6] as usize;
let n_ig = item[STN_OFFSET + 7] as usize;
let n_sec_audio = item[STN_OFFSET + 8] as usize;
let n_sec_video = item[STN_OFFSET + 9] as usize;
let _n_pip_pg = item[STN_OFFSET + 10] as usize;
let n_dv = item[STN_OFFSET + 11] as usize;
let mut spos = STN_OFFSET + 16;
// Primary video
for _ in 0..n_video {
if let Some((entry, next)) = parse_stream_entry(item, spos, 1) {
streams.push(entry);
spos = next;
} else { break; }
}
// Primary audio
for _ in 0..n_audio {
if let Some((entry, next)) = parse_stream_entry(item, spos, 2) {
streams.push(entry);
spos = next;
} else { break; }
}
// PG subtitles
for _ in 0..n_pg {
if let Some((entry, next)) = parse_stream_entry(item, spos, 3) {
streams.push(entry);
spos = next;
} else { break; }
}
// IG (skip but advance)
for _ in 0..n_ig {
if let Some((_, next)) = parse_stream_entry(item, spos, 4) {
spos = next;
} else { break; }
}
// Secondary audio
for _ in 0..n_sec_audio {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 2) {
entry.stream_type = 5;
entry.secondary = true;
streams.push(entry);
// Skip extra ref bytes: num_refs(1) + reserved(1) + refs + padding
if next < item.len() {
let n_refs = item[next] as usize;
spos = next + 2 + n_refs + (n_refs % 2);
} else { spos = next; }
} else { break; }
}
// Secondary video (PiP)
for _ in 0..n_sec_video {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
entry.stream_type = 6;
entry.secondary = true;
streams.push(entry);
// Skip extra ref bytes (audio refs + PG refs)
if next + 2 < item.len() {
let n_arefs = item[next] as usize;
let after_arefs = next + 2 + n_arefs + (n_arefs % 2);
if after_arefs < item.len() {
let n_prefs = item[after_arefs] as usize;
spos = after_arefs + 2 + n_prefs + (n_prefs % 2);
} else { spos = after_arefs; }
} else { spos = next; }
} else { break; }
}
// Dolby Vision enhancement layer
for _ in 0..n_dv {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
entry.stream_type = 7;
entry.secondary = true;
streams.push(entry);
spos = next;
} else { break; }
}
}
@@ -117,7 +197,86 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
Ok(Playlist {
version,
play_items,
video_stream_count: video_streams,
audio_stream_count: audio_streams,
streams,
})
}
/// Parse one stream entry from the STN table.
/// Returns (StreamEntry, next position) or None.
fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
if pos + 2 > item.len() { return None; }
// Stream entry: length(1) + data
let se_len = item[pos] as usize;
let se_end = pos + 1 + se_len;
if se_end > item.len() { return None; }
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
} else {
0
};
// Stream attributes: length(1) + coding_type(1) + format-specific data
if se_end + 2 > item.len() { return None; }
let sa_len = item[se_end] as usize;
let sa_end = se_end + 1 + sa_len;
if sa_end > item.len() || sa_len < 1 { return None; }
let sa = &item[se_end + 1..se_end + 1 + sa_len];
let coding_type = sa[0];
let mut video_format = 0u8;
let mut video_rate = 0u8;
let mut audio_format = 0u8;
let mut audio_rate = 0u8;
let mut dynamic_range = 0u8;
let mut color_space_val = 0u8;
let mut language = String::new();
match stream_type {
1 => {
// Video: coding_type(1) + format_rate(1) + [hdr_info(1) if HEVC]
if sa.len() >= 2 {
video_format = (sa[1] >> 4) & 0x0F;
video_rate = sa[1] & 0x0F;
}
if coding_type == 0x24 && sa.len() > 2 {
dynamic_range = (sa[2] >> 4) & 0x0F;
color_space_val = sa[2] & 0x0F;
}
}
2 => {
// Audio: coding_type(1) + format_rate(1) + language(3)
if sa.len() >= 2 {
audio_format = (sa[1] >> 4) & 0x0F;
audio_rate = sa[1] & 0x0F;
}
if sa.len() >= 5 {
language = String::from_utf8_lossy(&sa[2..5]).to_string();
}
}
3 | 4 => {
// PG/IG: coding_type(1) + language(3)
if sa.len() >= 4 {
language = String::from_utf8_lossy(&sa[1..4]).to_string();
}
}
_ => {}
}
Some((StreamEntry {
stream_type,
pid,
coding_type,
video_format,
video_rate,
audio_format,
audio_rate,
language,
dynamic_range,
color_space: color_space_val,
secondary: false,
}, sa_end))
}
+44 -29
View File
@@ -1,61 +1,76 @@
//! 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.
//! The trait methods correspond to the 10 command handlers in the per-drive
//!
//! x86 dispatch order (proven from code + hardware traces):
//! 1. unlock() — try activate raw mode
//! 2. load_firmware() — if unlock fails, write ld_microcode then retry
//! 3. calibrate() — probe disc zones, build speed table, triple SET_CD_SPEED
//! 5. read_register() — read hardware registers A and B (mid-rip, retried)
//! 6. status() — query feature flags
//! 7. probe() — parameterized register read
//! 8. set_read_speed()— per-zone SET_CD_SPEED during content reads
//!
//! The full init sequence is:
//! unlock → [load_firmware if fail] × 6 → calibrate × 6 →
//! drive_info → registers × 5 → status × 6 → probe
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.
/// Sends READ_BUFFER with drive-specific mode/buf_id.
/// Checks response against drive_signature and mode_active_magic ("MMkv").
/// Returns Ok if mode already active (warm), Err if firmware needed (cold).
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
///
/// 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>>;
/// 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]>;
/// Sends WRITE_BUFFER mode=6 with ld_microcode (1888 bytes).
/// Verifies with READ_BUFFER buf=0x45 (expects response == 2).
/// Then calls unlock() twice to activate mode.
/// Only called when unlock() fails (cold boot, firmware not in drive RAM).
fn load_firmware(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
///
/// 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.
/// Sends pre-built hardware_register_a_cdb. Returns 16 bytes from
/// the 36-byte response at offset [4:20].
fn read_register_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]>;
///
/// Sends pre-built hardware_register_b_cdb. Returns 16 bytes from
/// the 36-byte response at offset [4:20].
fn read_register_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]>;
///
/// Probes disc zones via READ_BUFFER sub_cmd=0x14, builds speed table,
/// then commits with triple SET_CD_SPEED (max → nominal → max).
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
///
/// 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.
/// Returns 16 bytes of feature data via READ_BUFFER sub_cmd=0x13.
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>;
/// Looks up LBA in speed_zone_table, sends SET_CD_SPEED.
/// Called by x86 before each zone change during content reads.
fn set_read_speed(&mut self, scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()>;
fn timing(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
/// Check if raw disc access mode is currently enabled.
/// Full init sequence — matches x86 dispatch order.
///
/// unlock → [load_firmware if fail] × 6 → calibrate × 6
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
/// Check if raw disc access mode is currently active.
fn is_unlocked(&self) -> bool;
}
+473 -199
View File
@@ -1,12 +1,11 @@
//! 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-A: mode=0x01, buffer_id=0x44 (all 10 handlers)
//! MT1959-B: mode=0x02, buffer_id=0x77 (handlers 4-9, 0-3 are no-ops)
//!
//! The 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.
//! The handler logic is identical across all 206 drives — only the
//! profile data differs (signature, register CDBs, microcode, etc).
//!
use crate::error::{Error, Result};
use crate::profile::DriveProfile;
@@ -19,8 +18,15 @@ pub struct Mt1959 {
mode: u8,
buffer_id: u8,
unlocked: bool,
/// Speed table: 64 × u16, built by calibrate().
/// Stores zone boundary addresses from disc surface probes.
speed_table: [u16; 64],
/// Total disc sectors — from READ CAPACITY.
disc_sectors: u32,
calibrated: bool,
/// 4 config bytes stored by calibrate() from initial probe response.
/// [0]=speed_mult, [1]=data_byte, [2]=data_byte, [3]=last_speed
calibration_config: [u8; 4],
}
impl Mt1959 {
@@ -33,162 +39,185 @@ impl Mt1959 {
buffer_id,
unlocked: false,
speed_table: [0u16; 64],
disc_sectors: 0,
calibrated: false,
calibration_config: [0u8; 4],
}
}
/// 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].
/// Build READ_BUFFER with sub_cmd in CDB[3] and address in CDB[4:6].
fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
[
0x3C,
self.mode,
self.buffer_id,
sub_cmd,
(address >> 8) as u8,
address as u8,
0x00,
0x00,
length,
0x00,
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,
});
/// Calls dynamic_read_buffer, validates response size matches expected.
/// Returns Ok(bytes_transferred) or Err.
fn read_buffer_probe(
&self, scsi: &mut dyn ScsiTransport,
sub_cmd: u8, address: u16, buf: &mut [u8], expected: usize,
) -> Result<usize> {
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
if result.bytes_transferred != expected {
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
}
// 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)
Ok(result.bytes_transferred)
}
/// 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)?;
}
/// Sends SET_CD_SPEED from CDB template at 0x9A78: BB 00 FF FF FF FF...
fn set_cd_speed_max(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let cdb = scsi::build_set_cd_speed(0xFFFF);
let mut dummy = [0u8; 0];
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
Ok(())
}
/// 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.
/// Build and send a custom SET_CD_SPEED with a specific speed value.
fn set_cd_speed(&self, scsi: &mut dyn ScsiTransport, speed: u16) -> Result<()> {
let cdb = scsi::build_set_cd_speed(speed);
let mut dummy = [0u8; 0];
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
Ok(())
}
/// Core unlock function. Returns the full response on success.
///
/// r3 = unlock_init_value (1)
/// sp[0x1C] = r3
/// r3 += unlock_response_size_minus_init (0x3F)
/// sp[0] = r3 (= 64 = response size)
/// scsi_cmd_wrapper(result, 0x0A, unlock_CDB, response)
/// check response[0:4] == drive_signature (LE u32)
/// check response[12:16] == "MMkv" (0x766B4D4D LE)
/// check response[16:20] version range
fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
let response_size = self.profile.unlock_init_value as u32
+ self.profile.unlock_response_size_minus_init as u32;
let cdb = [
0x3C, self.mode, self.buffer_id,
0x00, 0x00, 0x00,
0x00, 0x00, response_size as u8, 0x00,
];
let mut response = vec![0u8; response_size as usize];
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
if response.len() >= 4 {
let got = &response[0..4];
if got != self.profile.drive_signature {
return Err(Error::SignatureMismatch {
expected: self.profile.drive_signature,
got: got.try_into().unwrap_or([0; 4]),
});
}
}
if response.len() >= 16 && &response[12..16] != b"MMkv" {
return Err(Error::UnlockFailed {
detail: format!(
"mode not active: {:02x}{:02x}{:02x}{:02x}",
response[12], response[13], response[14], response[15]
),
});
}
// If signature + MMkv match, version is almost always OK.
self.unlocked = true;
Ok(response)
}
/// Sends a short READ_BUFFER probe, retries up to 5 times.
fn validate(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
for _attempt in 0..5 {
let cdb = [
0x3C, self.mode, self.buffer_id,
0x00, 0x00, 0x00,
0x00, 0x00, 0x04, 0x00,
];
let mut resp = [0u8; 4];
if scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000).is_ok() {
return Ok(());
}
}
Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 })
}
}
impl Platform for Mt1959 {
/// Thin wrapper → do_unlock(). Returns Ok if mode active, Err if not.
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
self.do_unlock(scsi)?;
Ok(())
}
///
/// 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);
/// Two variants with different upload sequences:
///
/// 1. WRITE_BUFFER mode=6 with ld_microcode
/// 2. READ_BUFFER buf=0x45 verify (expect response == 2)
/// 3. do_unlock() × 2
///
/// 1. WRITE_BUFFER with mode=2 buf=0x77 initial handshake (0x9C0 bytes)
/// 2. Check response == 2
/// 3. READ_BUFFER at offset 0x3000 (16 bytes, firmware metadata check)
/// 4. WRITE_BUFFER mode=6 with ld_microcode (16 bytes from payload+16)
/// 5. READ verify
/// 6. do_unlock() × 5 retries
fn load_firmware(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let microcode = &self.profile.ld_microcode;
if microcode.is_empty() {
return Err(Error::UnlockFailed {
detail: "no ld_microcode in profile".into(),
});
}
// 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)?;
if self.mode == 0x01 {
// ── MT1959-A path ──────────────────────────────────────────
self.load_firmware_a(scsi)?;
} else {
// ── MT1959-B path ──────────────────────────────────────────
self.load_firmware_b(scsi)?;
}
buf.extend_from_slice(&status);
Ok(buf)
Ok(())
}
/// 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)?;
/// do_unlock → validate × 5 → send hardware_register_a_cdb (10B) →
/// check 36B response → return response[4:20] (16 bytes)
fn read_register_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]> {
if !self.unlocked { self.do_unlock(scsi)?; }
self.validate(scsi)?;
let 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 cdb = &self.profile.hardware_register_a_cdb;
if cdb.len() != 10 {
return Err(Error::UnlockFailed { detail: "missing register_a_cdb".into() });
}
let mut response = [0u8; 36];
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
scsi.execute(cdb, DataDirection::FromDevice, &mut response, 30_000)?;
let mut out = [0u8; 16];
out.copy_from_slice(&response[4..20]);
Ok(out)
}
fn read_register_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]> {
if !self.unlocked { self.do_unlock(scsi)?; }
self.validate(scsi)?;
let cdb = &self.profile.hardware_register_b_cdb;
if cdb.len() != 10 {
return Err(Error::UnlockFailed { detail: "missing register_b_cdb".into() });
}
let mut response = [0u8; 36];
scsi.execute(cdb, DataDirection::FromDevice, &mut response, 30_000)?;
let mut out = [0u8; 16];
out.copy_from_slice(&response[4..20]);
@@ -196,94 +225,157 @@ impl Platform for Mt1959 {
}
///
/// 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.
/// Full calibration sequence:
/// 1. do_unlock()
/// 2. init_timing()
/// 3. read_buffer_probe(0x12, init_addr, 4) — calibration init
/// 4. validate_with_retry()
/// 5. memset speed_table to 0
/// 6. First probe: sub_cmd=0x14, addr=0 → initial speed
/// 7. Scan loop: addresses 0x0000-0x5800, step 0x100, detect zone boundaries
/// 8. Build loop: scan all zones up to 0x10000, store in speed_table[(speed>>1)-1]
/// 9. Triple SET_CD_SPEED: max → drive_nominal_speed → max
/// 10. Store 4 calibration config bytes
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
self.ensure_unlocked(scsi)?;
self.validate(scsi)?;
// Step 1: ensure unlocked
if !self.unlocked { self.do_unlock(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;
}
}
// Step 2: read disc capacity
let cap_cdb = [0x25u8, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let mut cap_buf = [0u8; 8];
if scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000).is_ok() {
self.disc_sectors = u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]) + 1;
}
// Set max speed after calibration
self.set_cd_speed(scsi, 0xFFFF)?;
// Step 3: calibration init — sub_cmd 0x12
let init_addr: u16 = 0x0100; // TODO: detect disc type
let mut init_resp = [0u8; 4];
let _ = self.read_buffer_probe(scsi, 0x12, init_addr, &mut init_resp, 4);
// Step 4: validate
self.validate(scsi)?;
// Step 5: clear speed table
self.speed_table = [0u16; 64];
// Step 6: first probe
let mut probe_buf = [0u8; 4];
let _ = self.read_buffer_probe(scsi, 0x14, 0, &mut probe_buf, 4);
let initial_speed = probe_buf[0];
self.calibration_config[0] = probe_buf[0];
self.calibration_config[1] = probe_buf[1];
self.calibration_config[2] = probe_buf[2];
// Step 7: scan loop — find zone boundaries (0x0000-0x5800, step 0x100)
let mut addr: u16 = 0;
let mut prev_speed = initial_speed;
while addr < 0x5800 {
let mut resp = [0u8; 4];
if self.read_buffer_probe(scsi, 0x14, addr, &mut resp, 4).is_err() {
self.speed_table = [0u16; 64];
self.calibration_config = [0u8; 4];
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
}
if resp[0] != prev_speed {
prev_speed = resp[0];
}
addr = addr.wrapping_add(0x100);
}
// Step 8: build speed table — probe all zones up to 0x10000
let mut addr: u32 = 0;
let mut prev_speed: u8 = 0;
while addr < 0x10000 {
let mut resp = [0u8; 4];
if self.read_buffer_probe(scsi, 0x14, addr as u16, &mut resp, 4).is_err() {
break;
}
let speed = resp[0];
if speed > prev_speed && speed > 0 {
let idx = ((speed as usize) >> 1).saturating_sub(1);
if idx < 64 && self.speed_table[idx] == 0 {
self.speed_table[idx] = addr as u16;
}
}
prev_speed = speed;
addr += 0x100;
}
self.calibration_config[3] = prev_speed;
// Step 9: triple SET_CD_SPEED — max → nominal → max
let _ = self.set_cd_speed_max(scsi);
// Send drive_nominal_speed_cdb (the specific speed from the profile)
if self.profile.drive_nominal_speed_cdb.len() >= 6 {
let cdb = &self.profile.drive_nominal_speed_cdb;
let mut dummy = [0u8; 0];
let _ = scsi.execute(cdb, DataDirection::None, &mut dummy, 5_000);
}
let _ = self.set_cd_speed_max(scsi);
self.calibrated = true;
Ok(())
}
///
/// NOT a no-op. Sends 16 bytes from a data buffer to the host via host_write.
/// In our context: the x86 reads 16 bytes back. We return the data.
/// For now we just acknowledge — the x86 calls this to confirm VM is alive.
fn keepalive(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
// In driver context: no host_write mechanism. This is a VM-to-host
// communication that doesn't translate to a SCSI command.
Ok(())
}
///
/// Sends READ BUFFER with sub-command 0x13, reads 36 bytes.
/// Checks signature at [0:4], returns feature data from [4:20].
/// Full status check:
/// 1. Read 4 bytes from host (param input)
/// 2. Validate param == 4
/// 3. do_unlock()
/// 4. validate_with_retry()
/// 6. read_buffer_probe(0x13, addr, response, 36)
/// 7. validate_with_retry() again
/// 8. Check REV32(response[0:4]) == 0x00220054
/// 9. If mismatch: call fallback() with speed_zone_table
/// 10. If match: host_write(response[4:20]) — 16 bytes features
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus> {
self.ensure_unlocked(scsi)?;
if !self.unlocked { self.do_unlock(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);
self.validate(scsi)?;
let sig = u32::from_be_bytes(response[0..4].try_into().unwrap());
let expected = 0x00220054;
let mut features = [0u8; 16];
features.copy_from_slice(&response[4..20]);
Ok(DriveStatus {
unlocked: got_sig == expected_sig,
unlocked: sig == expected,
features,
})
}
fn probe(&mut self, scsi: &mut dyn ScsiTransport, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
///
/// Three code paths based on param count:
/// param=1 (5 bytes in): sub_cmd + nothing else → dynamic_read_buffer
/// param=5 (5+4 bytes in): sub_cmd + address → dynamic_read_buffer with addr
/// param=9 (9+ bytes in): builds full 12-byte CDB on stack:
/// [0x3C, mode, buf_id, sub_cmd, addr[2], addr[1], addr[0], len[2], len[1], len[0]]
/// then calls scsi_cmd_wrapper directly
fn probe(
&mut self, scsi: &mut dyn ScsiTransport,
sub_cmd: u8, address: u32, length: u32,
) -> Result<Vec<u8>> {
let cdb = [
0x3C,
self.mode,
self.buffer_id,
sub_cmd,
(address >> 16) as u8,
(address >> 8) as u8,
address as u8,
(length >> 16) as u8,
(length >> 8) as u8,
length as u8,
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)?;
@@ -292,34 +384,130 @@ impl Platform for Mt1959 {
}
///
/// 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 management for content reads. Called per zone change.
///
/// 1. Read 4-byte target LBA from host
/// 2. REV(LBA) for big-endian comparison
/// 3. Search speed_table[64] for closest entry to LBA
/// - Each entry is u16 zone boundary address
/// - Find entry with smallest |entry - LBA| distance
/// 4. If no match found → call fallback() with speed_calc_table
/// 5. If match:
/// a. r6 = speed_table[best_idx] (the zone address = speed value)
/// b. Position check: read_buffer_probe(0x14, 0x100 | rev16(r6), 4)
/// c. SET_CD_SPEED max (BB 00 FF FF FF FF)
/// d. Build custom SET_CD_SPEED: BB 00 [r6>>8] [r6&FF] FF FF 00...
/// e. Send custom SET_CD_SPEED
/// f. Return next speed_table entry to host
fn set_read_speed(&mut self, scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
if !self.calibrated {
return Ok(());
}
// Speed optimization from calibration
if self.calibrated {
let speed = self.lookup_speed(lba);
if speed > 0 {
let _ = self.set_cd_speed(scsi, speed);
// the byte-swapped LBA against table entries. Since entries are
// zone boundary addresses (u16), we compare the low 16 bits.
// Step 2-3: search speed_table for closest entry
let mut best_idx: usize = 0;
let mut best_diff: u32 = 0x10000000;
let mut found = false;
for i in 0..64 {
let entry = self.speed_table[i] as u32;
if entry == 0 { continue; }
let diff = if lba > entry { lba - entry } else { entry - lba };
if diff < best_diff {
best_diff = diff;
best_idx = i;
found = true;
}
}
// 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)
if !found {
// For now, skip speed adjustment when no table match
return Ok(());
}
// Step 5a: get the matched speed value
let speed_val = self.speed_table[best_idx];
// Step 5b: position check probe
let probe_addr = 0x0100 | (speed_val.swap_bytes() as u16);
let mut probe_resp = [0u8; 4];
let _ = self.read_buffer_probe(scsi, 0x14, probe_addr, &mut probe_resp, 4);
// Step 5c: SET_CD_SPEED max
let _ = self.set_cd_speed_max(scsi);
// Step 5d-e: custom SET_CD_SPEED with the matched speed value
let _ = self.set_cd_speed(scsi, speed_val);
Ok(())
}
///
/// Reads 8 bytes from host, byte-swaps as 32-bit value, returns.
/// Has a helper for unaligned 4-byte reads with manual byte copy.
/// In driver context: no host communication, so this is a no-op.
fn timing(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
// VM-to-host timing measurement, not a SCSI command.
Ok(())
}
/// Full init sequence — matches x86 dispatch exactly.
///
/// Phase 1: cmd 0 → [cmd 1 if fail] × 6 retries (unlock + fw upload)
/// Phase 2: cmd 4 × 6 retries (calibrate)
/// Phase 3: cmd 7 → [cmd 5 fallback] (drive info)
/// Phase 4: cmd 2 + cmd 3 × 5 retries (registers)
/// Phase 5: cmd 9 × 6 retries (status)
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
// Phase 1: Unlock + firmware upload (6 retries)
let mut unlocked = false;
for _attempt in 0..6 {
match self.unlock(scsi) {
Ok(_) => { unlocked = true; break; }
Err(_) => {
if self.load_firmware(scsi).is_ok() {
unlocked = true;
break;
}
}
}
}
if !unlocked {
return Err(Error::UnlockFailed {
detail: "failed after 6 attempts (unlock + load_firmware)".into(),
});
}
// Phase 2: Calibrate (6 retries)
let mut calibrated = false;
for _attempt in 0..6 {
if self.calibrate(scsi).is_ok() {
calibrated = true;
break;
}
}
if !calibrated {
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
}
// If fails: cmd 5 fallback (keepalive)
// In our context: this fetches a display string, not critical for reads
let _ = self.probe(scsi, 0x00, 0, 0x3FF);
for _attempt in 0..5 {
let a_ok = self.read_register_a(scsi).is_ok();
let b_ok = self.read_register_b(scsi).is_ok();
if a_ok && b_ok { break; }
}
// Phase 5: Status (6 retries)
for _attempt in 0..6 {
if self.status(scsi).is_ok() { break; }
}
Ok(())
}
@@ -327,3 +515,89 @@ impl Platform for Mt1959 {
self.unlocked
}
}
// ── Private firmware upload variants ───────────────────────────────────
impl Mt1959 {
///
/// Simple: WRITE all microcode → verify buf=0x45 → unlock × 2.
fn load_firmware_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let microcode = &self.profile.ld_microcode;
let len = microcode.len();
// WRITE_BUFFER mode=6: send entire microcode payload
let cdb = [
0x3B, 0x06, 0x00,
0x00, 0x00, 0x00,
(len >> 16) as u8, (len >> 8) as u8, len as u8,
0x00,
];
let mut data = microcode.clone();
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
let verify_cdb = [0x3C, 0x01, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00];
let mut verify_resp = [0u8; 4];
let _ = scsi.execute(
&verify_cdb, DataDirection::FromDevice, &mut verify_resp, 5_000,
);
// Unlock × 2
self.do_unlock(scsi)?;
self.do_unlock(scsi)?;
Ok(())
}
///
/// 1. MODE SELECT (0x55) — send 2496 bytes from ld_microcode
/// 2. Check result == 2 (firmware accepted)
/// 3. READ_BUFFER mode=6 offset=0x3000 (16 bytes firmware metadata)
/// 4. WRITE_BUFFER mode=6 (16 bytes from profile's fw_write_data)
/// 5. Vendor verify command (from profile's verify_cdb)
/// 6. do_unlock() × 5 retries + 1 final
fn load_firmware_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let microcode = &self.profile.ld_microcode;
// Step 1: MODE SELECT (0x55) with vendor mode page data
// Transfer: 0x9C0 = 2496 bytes from the firmware payload
let write_len = 0x9C0usize.min(microcode.len());
let mode_select_cdb = [
0x55, 0x10, 0x00,
0x00, 0x00, 0x00,
(write_len >> 16) as u8, (write_len >> 8) as u8, write_len as u8,
0x00,
];
let mut data = microcode[..write_len].to_vec();
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// The execute above will Err on SCSI failure. If we get here, command accepted.
// Step 3: READ_BUFFER mode=6, offset=0x3000, 16 bytes
let read_meta_cdb = [0x3C, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00];
let mut meta_resp = [0u8; 16];
let _ = scsi.execute(&read_meta_cdb, DataDirection::FromDevice, &mut meta_resp, 5_000);
// Step 4: WRITE_BUFFER mode=6, 16 bytes
// This data is stored in profile.fw_write_data (16 bytes)
if self.profile.fw_write_data.len() >= 16 {
let write2_cdb = [0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
let mut data2 = self.profile.fw_write_data[..16].to_vec();
let _ = scsi.execute(&write2_cdb, DataDirection::ToDevice, &mut data2, 5_000);
}
// Step 5: Vendor verify command
if self.profile.verify_cdb.len() >= 10 {
let mut dummy = [0u8; 0];
let _ = scsi.execute(&self.profile.verify_cdb, DataDirection::None, &mut dummy, 5_000);
}
// Step 6: do_unlock() × 5 retries + 1 confirmation
for _attempt in 0..5 {
if self.do_unlock(scsi).is_ok() {
let _ = self.do_unlock(scsi);
return Ok(());
}
}
self.do_unlock(scsi)?;
Ok(())
}
}
+94 -140
View File
@@ -1,20 +1,21 @@
//! 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.
//! The profile contains all per-drive data needed by the MT1959 platform handlers.
//! Profiles are loaded from JSON so new drives can be added without rebuilding.
use serde::Deserialize;
use crate::error::{Error, Result};
/// Per-drive profile containing SCSI parameters for raw disc access.
///
#[derive(Debug, Clone, Deserialize)]
pub struct DriveProfile {
// ── Drive identity (from INQUIRY + GET_CONFIG) ─────────────────────
/// Drive vendor from INQUIRY[8:16] (e.g. "HL-DT-ST")
#[serde(default)]
pub vendor_id: String,
/// Drive product (devtype) from INQUIRY product field (e.g. "BD-RE")
/// Drive product from INQUIRY[16:32] (e.g. "BD-RE BU40N")
#[serde(default)]
pub product_id: String,
@@ -30,42 +31,77 @@ pub struct DriveProfile {
#[serde(default)]
pub firmware_date: String,
/// Chipset manufacturer determining unlock/read command structure.
// ── Platform variant ───────────────────────────────────────────────
/// Chipset family: "mediatek" or "renesas".
#[serde(default)]
pub chipset: Chipset,
/// READ BUFFER mode byte for unlock CDB (e.g. 0x01 for MT1959-A, 0x02 for MT1959-B).
/// Program variant: "mt1959_a" or "mt1959_b".
/// Determines unlock mode/buf_id and handler layout.
#[serde(default)]
pub program: String,
/// READ_BUFFER mode byte (0x01 for mt1959_a, 0x02 for mt1959_b).
#[serde(default = "default_unlock_mode")]
pub unlock_mode: u8,
/// READ BUFFER buffer ID for unlock CDB (e.g. 0x44 for MT1959-A, 0x77 for MT1959-B).
/// READ_BUFFER buffer ID (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,
/// Used with unlock_response_size_minus_init to compute response size.
#[serde(default = "default_init_value")]
pub unlock_init_value: u8,
/// Profile version string.
#[serde(default)]
pub drive_version: String,
/// Response size = unlock_init_value + this (e.g. 1 + 63 = 64).
#[serde(default = "default_response_size_minus_init")]
pub unlock_response_size_minus_init: u8,
/// Expected response signature bytes [0:4] from the enable command.
/// Per-drive signature checked against unlock response[0:4].
#[serde(default, deserialize_with = "deserialize_hex4")]
pub signature: [u8; 4],
pub drive_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.
/// Uploaded on cold boot when unlock fails. ~1888 bytes typically.
/// Contains volatile RAM-only runtime code for the drive's MediaTek SOC.
#[serde(default, deserialize_with = "deserialize_base64")]
pub ld_microcode: Vec<u8>,
// ── Handlers 2/3: register reads ──────────────────────────────────
#[serde(default, deserialize_with = "deserialize_hex_vec")]
pub unlock_cdb: Vec<u8>,
pub hardware_register_a_cdb: Vec<u8>,
#[serde(default, deserialize_with = "deserialize_hex_vec")]
pub hardware_register_b_cdb: Vec<u8>,
/// 16 bytes written via WRITE_BUFFER mode=6 during B firmware upload.
#[serde(default, deserialize_with = "deserialize_hex_vec")]
pub fw_write_data: Vec<u8>,
/// Vendor-specific verify CDB after B firmware upload (10 bytes).
/// B-only. Empty for A-variant drives.
#[serde(default, deserialize_with = "deserialize_hex_vec")]
pub verify_cdb: Vec<u8>,
/// Pre-built SET_CD_SPEED CDB with drive's nominal speed (12 bytes).
/// Used in calibration "triple play": max → this → max.
#[serde(default, deserialize_with = "deserialize_hex_vec")]
pub drive_nominal_speed_cdb: Vec<u8>,
/// for per-zone speed decisions. Per-drive calibration constants.
#[serde(default, deserialize_with = "deserialize_hex_vec")]
pub speed_zone_table: Vec<u8>,
/// raw sector reads for speed math.
#[serde(default, deserialize_with = "deserialize_hex_vec")]
pub speed_calc_table: Vec<u8>,
/// 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)]
@@ -82,41 +118,35 @@ pub struct DriveProfile {
/// Drive supports unrestricted read speed.
#[serde(default)]
pub unrestricted_speed: bool,
// ── Metadata ──────────────────────────────────────────────────────
#[serde(default)]
pub drive_id: String,
#[serde(default)]
pub drive_version: String,
}
fn default_verify() -> [u8; 4] {
*b"MMkv"
}
fn default_unlock_mode() -> u8 { 0x01 }
fn default_unlock_buf_id() -> u8 { 0x44 }
fn default_init_value() -> u8 { 1 }
fn default_response_size_minus_init() -> u8 { 0x3F }
fn default_unlock_mode() -> u8 {
0x01
}
fn default_unlock_buf_id() -> u8 {
0x44
}
/// Drive chipset — determines CDB structure for unlock and raw read commands.
/// Drive chipset family.
#[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")]
Renesas,
}
impl Default for Chipset {
fn default() -> Self {
Chipset::MediaTek
}
fn default() -> Self { Chipset::MediaTek }
}
impl Chipset {
/// Human-readable name for this chipset.
pub fn name(&self) -> &'static str {
match self {
Chipset::MediaTek => "MediaTek MT1959",
@@ -125,7 +155,8 @@ impl Chipset {
}
}
/// Parse a hex string like "999ec375" into [u8; 4].
// ── Hex/base64 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()) });
@@ -138,7 +169,6 @@ fn parse_hex4(s: &str) -> Result<[u8; 4]> {
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() });
@@ -151,94 +181,33 @@ fn parse_hex(s: &str) -> Result<Vec<u8>> {
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>,
{
where D: serde::Deserializer<'de> {
let s = String::deserialize(deserializer)?;
if s.is_empty() { return Ok(Vec::new()); }
parse_hex(&s).map_err(serde::de::Error::custom)
}
/// 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");
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),
})
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)?;
if s.is_empty() { return Ok(Vec::new()); }
base64::engine::general_purpose::STANDARD
.decode(&s)
.map_err(serde::de::Error::custom)
}
// ── Loading ────────────────────────────────────────────────────────────
/// 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.
@@ -252,28 +221,13 @@ pub fn load_all(path: &std::path::Path) -> Result<Vec<DriveProfile>> {
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)
let arr: Vec<DriveProfile> = 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)
Ok(arr)
}
/// 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],
drive_id: &crate::identity::DriveId,
@@ -282,14 +236,14 @@ pub fn find_by_drive_id<'a>(
let r = drive_id.product_revision.trim();
let vs = drive_id.vendor_specific.trim();
// Match all four INQUIRY fields for precise identification
// Match all four INQUIRY fields
profiles.iter().find(|p| {
p.vendor_id.trim() == v
&& p.product_revision.trim() == r
&& p.vendor_specific.trim() == vs
&& p.firmware_date.trim() == drive_id.firmware_date.trim()
})
// Fallback: match without date (for drives where 010C isn't available)
// Fallback: match without date
.or_else(|| profiles.iter().find(|p| {
p.vendor_id.trim() == v
&& p.product_revision.trim() == r
-248
View File
@@ -1,248 +0,0 @@
//! SCSI/MMC command interface.
//!
//! Platform backends:
//! - Linux: SG_IO ioctl
//! - macOS: IOKit SCSI passthrough (planned)
//! - Windows: SPTI (planned)
use crate::error::{Error, Result};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DataDirection {
None,
FromDevice,
ToDevice,
}
#[derive(Debug)]
pub struct ScsiResult {
pub status: u8,
pub bytes_transferred: usize,
pub sense: [u8; 32],
}
/// Low-level SCSI transport — implemented per platform.
pub trait ScsiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
}
// ─── Linux: SG_IO ───────────────────────────────────────────────────────────
#[cfg(target_os = "linux")]
const SG_IO: u32 = 0x2285;
#[cfg(target_os = "linux")]
const SG_DXFER_NONE: i32 = -1;
#[cfg(target_os = "linux")]
const SG_DXFER_TO_DEV: i32 = -2;
#[cfg(target_os = "linux")]
const SG_DXFER_FROM_DEV: i32 = -3;
#[cfg(target_os = "linux")]
#[repr(C)]
#[allow(non_camel_case_types)]
struct sg_io_hdr {
interface_id: i32,
dxfer_direction: i32,
cmd_len: u8,
mx_sb_len: u8,
iovec_count: u16,
dxfer_len: u32,
dxferp: *mut u8,
cmdp: *const u8,
sbp: *mut u8,
timeout: u32,
flags: u32,
pack_id: i32,
usr_ptr: *mut libc::c_void,
status: u8,
masked_status: u8,
msg_status: u8,
sb_len_wr: u8,
host_status: u16,
driver_status: u16,
resid: i32,
duration: u32,
info: u32,
}
#[cfg(target_os = "linux")]
pub struct SgIoTransport {
fd: i32,
}
#[cfg(target_os = "linux")]
impl SgIoTransport {
pub fn open(device: &Path) -> Result<Self> {
use std::os::unix::ffi::OsStrExt;
let path_bytes = device.as_os_str().as_bytes();
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
c_path.extend_from_slice(path_bytes);
c_path.push(0);
let fd = unsafe { libc::open(c_path.as_ptr() as *const libc::c_char, libc::O_RDWR | libc::O_NONBLOCK) };
if fd < 0 {
return Err(Error::DeviceNotFound { path: device.display().to_string() });
}
Ok(SgIoTransport { fd })
}
}
#[cfg(target_os = "linux")]
impl Drop for SgIoTransport {
fn drop(&mut self) {
unsafe { libc::close(self.fd); }
}
}
#[cfg(target_os = "linux")]
impl ScsiTransport for SgIoTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
let mut sense = [0u8; 32];
let dxfer_direction = match direction {
DataDirection::None => SG_DXFER_NONE,
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
DataDirection::ToDevice => SG_DXFER_TO_DEV,
};
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
hdr.interface_id = b'S' as i32;
hdr.dxfer_direction = dxfer_direction;
hdr.cmd_len = cdb.len() as u8;
hdr.mx_sb_len = sense.len() as u8;
hdr.dxfer_len = data.len() as u32;
hdr.dxferp = data.as_mut_ptr();
hdr.cmdp = cdb.as_ptr();
hdr.sbp = sense.as_mut_ptr();
hdr.timeout = timeout_ms;
let ret = unsafe {
libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr)
};
if ret < 0 {
return Err(Error::IoError { source: std::io::Error::last_os_error() });
}
let bytes_transferred = (data.len() as i32 - hdr.resid) as usize;
if hdr.status != 0 {
let sense_key = if hdr.sb_len_wr > 2 { sense[2] & 0x0F } else { 0 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: hdr.status,
sense_key,
});
}
Ok(ScsiResult {
status: hdr.status,
bytes_transferred,
sense,
})
}
}
// ─── macOS: IOKit (planned) ─────────────────────────────────────────────────
// TODO: IOKit MMC SCSI passthrough
// Use IOSCSIPeripheralDeviceType05 (MMC device nub)
// Send SCSITaskInterface commands via IOKit user client
// ─── Windows: SPTI (planned) ────────────────────────────────────────────────
// TODO: SCSI Pass Through Interface
// Use CreateFile on \\.\CdRomN
// Send IOCTL_SCSI_PASS_THROUGH_DIRECT
// ─── Platform-agnostic open ─────────────────────────────────────────────────
/// Open a SCSI transport for the given device path.
pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
#[cfg(target_os = "linux")]
{ Ok(Box::new(SgIoTransport::open(device)?)) }
#[cfg(not(target_os = "linux"))]
{ Err(Error::DeviceNotFound { path: format!("{}: platform not yet supported (Linux only)", device.display()) }) }
}
// ─── CDB builders (platform-agnostic) ───────────────────────────────────────
/// SCSI INQUIRY response.
#[derive(Debug, Clone)]
pub struct InquiryResult {
pub vendor_id: String,
pub model: String,
pub firmware: String,
pub raw: Vec<u8>,
}
/// Send INQUIRY command and parse the standard response fields.
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
let cdb = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
let mut buf = [0u8; 96];
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
let vendor = String::from_utf8_lossy(&buf[8..16]).trim().to_string();
let model = String::from_utf8_lossy(&buf[16..32]).trim().to_string();
let firmware = String::from_utf8_lossy(&buf[32..36]).trim().to_string();
Ok(InquiryResult {
vendor_id: vendor,
model,
firmware,
raw: buf.to_vec(),
})
}
/// Send GET CONFIGURATION for feature 0x010C (Firmware Information).
pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
let cdb = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
let mut buf = [0u8; 16];
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(buf.to_vec())
}
/// Build a READ BUFFER (0x3C) CDB.
pub fn build_read_buffer(mode: u8, buffer_id: u8, offset: u32, length: u32) -> [u8; 10] {
[
0x3C, mode, buffer_id,
(offset >> 16) as u8, (offset >> 8) as u8, offset as u8,
(length >> 16) as u8, (length >> 8) as u8, length as u8,
0x00,
]
}
/// Build a SET CD SPEED (0xBB) CDB.
pub fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
[
0xBB, 0x00,
(read_speed >> 8) as u8, read_speed as u8,
0xFF, 0xFF,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]
}
/// Build a READ(10) CDB with the raw read flag (0x08).
pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
[
0x28, 0x08,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00,
(count >> 8) as u8, count as u8,
0x00,
]
}
+120
View File
@@ -0,0 +1,120 @@
//! Linux SCSI transport via SG_IO ioctl.
use crate::error::{Error, Result};
use super::{ScsiTransport, ScsiResult, DataDirection};
use std::path::Path;
const SG_IO: u32 = 0x2285;
const SG_DXFER_NONE: i32 = -1;
const SG_DXFER_TO_DEV: i32 = -2;
const SG_DXFER_FROM_DEV: i32 = -3;
#[repr(C)]
#[allow(non_camel_case_types)]
struct sg_io_hdr {
interface_id: i32,
dxfer_direction: i32,
cmd_len: u8,
mx_sb_len: u8,
iovec_count: u16,
dxfer_len: u32,
dxferp: *mut u8,
cmdp: *const u8,
sbp: *mut u8,
timeout: u32,
flags: u32,
pack_id: i32,
usr_ptr: *mut libc::c_void,
status: u8,
masked_status: u8,
msg_status: u8,
sb_len_wr: u8,
host_status: u16,
driver_status: u16,
resid: i32,
duration: u32,
info: u32,
}
pub struct SgIoTransport {
fd: i32,
}
impl SgIoTransport {
pub fn open(device: &Path) -> Result<Self> {
use std::os::unix::ffi::OsStrExt;
let path_bytes = device.as_os_str().as_bytes();
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
c_path.extend_from_slice(path_bytes);
c_path.push(0);
let fd = unsafe {
libc::open(c_path.as_ptr() as *const libc::c_char, libc::O_RDWR | libc::O_NONBLOCK)
};
if fd < 0 {
return Err(Error::DeviceNotFound { path: device.display().to_string() });
}
Ok(SgIoTransport { fd })
}
}
impl Drop for SgIoTransport {
fn drop(&mut self) {
unsafe { libc::close(self.fd); }
}
}
impl ScsiTransport for SgIoTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
let mut sense = [0u8; 32];
let dxfer_direction = match direction {
DataDirection::None => SG_DXFER_NONE,
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
DataDirection::ToDevice => SG_DXFER_TO_DEV,
};
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
hdr.interface_id = b'S' as i32;
hdr.dxfer_direction = dxfer_direction;
hdr.cmd_len = cdb.len() as u8;
hdr.mx_sb_len = sense.len() as u8;
hdr.dxfer_len = data.len() as u32;
hdr.dxferp = data.as_mut_ptr();
hdr.cmdp = cdb.as_ptr();
hdr.sbp = sense.as_mut_ptr();
hdr.timeout = timeout_ms;
let ret = unsafe {
libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr)
};
if ret < 0 {
return Err(Error::IoError { source: std::io::Error::last_os_error() });
}
let bytes_transferred = (data.len() as i32 - hdr.resid) as usize;
if hdr.status != 0 {
let sense_key = if hdr.sb_len_wr > 2 { sense[2] & 0x0F } else { 0 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: hdr.status,
sense_key,
});
}
Ok(ScsiResult {
status: hdr.status,
bytes_transferred,
sense,
})
}
}
+465
View File
@@ -0,0 +1,465 @@
//! macOS SCSI transport via IOKit SCSITaskDeviceInterface.
//!
//! Sends SCSI commands to optical drives through IOKit's SCSI Architecture
//! Model family. Accepts BSD device paths like `/dev/disk2` or `/dev/rdisk2`.
//!
//! Requires exclusive access to the device — unmount the disc first:
//! `diskutil unmountDisk /dev/disk2`
use crate::error::{Error, Result};
use super::{DataDirection, ScsiResult, ScsiTransport};
use std::path::Path;
// ── IOKit / CoreFoundation type aliases ─────────────────────────────────────
type CFMutableDictionaryRef = *mut std::ffi::c_void;
type IOObject = u32;
type IOReturn = i32;
type MachPort = u32;
/// Opaque COM interface pointer — `*mut *mut VTable` (double-indirect).
/// IOKit plugins use COM-style vtables: the pointer points to a pointer
/// to the function table.
type ComRef = *mut *mut std::ffi::c_void;
const K_IO_RETURN_SUCCESS: IOReturn = 0;
// SCSI data transfer directions (SCSITaskLib.h)
const K_SCSI_DATA_TRANSFER_NO_DATA: u8 = 0;
const K_SCSI_DATA_TRANSFER_FROM_TARGET: u8 = 1;
const K_SCSI_DATA_TRANSFER_TO_TARGET: u8 = 2;
// SCSI task status values
const K_SCSI_TASK_STATUS_GOOD: u8 = 0x00;
const K_MAX_CDB_SIZE: usize = 16;
const K_SENSE_DATA_SIZE: usize = 32;
// ── IOKit plugin UUIDs ──────────────────────────────────────────────────────
// From IOKit/scsi/SCSITaskLib.h
/// kIOMMCDeviceUserClientTypeID — plugin type for MMC (optical) devices.
const K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
0x97, 0xAB, 0xCF, 0x5C, 0x45, 0x71, 0x11, 0xD6,
0xB6, 0xA0, 0x00, 0x30, 0x65, 0xA4, 0x7A, 0xEE,
];
/// kIOCFPlugInInterfaceID — base IOCFPlugin interface.
const K_IO_CFPLUGIN_INTERFACE_ID: [u8; 16] = [
0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4,
0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F,
];
/// kIOSCSITaskDeviceInterfaceID — the interface we QueryInterface for.
const K_IO_SCSI_TASK_DEVICE_INTERFACE_ID: [u8; 16] = [
0x61, 0x3E, 0x48, 0xB0, 0x30, 0x01, 0x11, 0xD6,
0xA4, 0xC0, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
];
// ── Scatter/gather element ──────────────────────────────────────────────────
#[repr(C)]
struct SCSITaskSGElement {
address: u64,
length: u64,
}
// ── External IOKit / CoreFoundation functions ───────────────────────────────
extern "C" {
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
fn IOBSDNameMatching(
master: MachPort,
options: u32,
bsd_name: *const u8,
) -> CFMutableDictionaryRef;
fn IOServiceGetMatchingService(
master: MachPort,
matching: CFMutableDictionaryRef,
) -> IOObject;
fn IOObjectRelease(object: IOObject) -> IOReturn;
fn IORegistryEntryGetParentEntry(
entry: IOObject,
plane: *const u8,
parent: *mut IOObject,
) -> IOReturn;
fn IOObjectConformsTo(object: IOObject, class_name: *const u8) -> u8;
fn IOCreatePlugInInterfaceForService(
service: IOObject,
plugin_type: *const [u8; 16],
interface_type: *const [u8; 16],
the_interface: *mut ComRef,
the_score: *mut i32,
) -> IOReturn;
}
// ── COM vtable helpers ──────────────────────────────────────────────────────
//
// IOKit plugin interfaces use COM-style vtables. A ComRef is **vtable —
// dereferencing once gives the vtable pointer, then index into it for
// individual function pointers.
//
// All vtable indices verified against Apple open source:
// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h
/// Read a function pointer from a COM vtable at the given index.
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
let vtable = *iface as *const *const std::ffi::c_void;
let fn_ptr = *vtable.add(index);
std::mem::transmute_copy(&fn_ptr)
}
/// Call Release (vtable index 3) on any COM interface.
fn com_release(iface: ComRef) {
type Fn = unsafe extern "C" fn(ComRef) -> u32;
unsafe {
let f: Fn = vtable_fn(iface, 3);
f(iface);
}
}
// ── SCSITaskDeviceInterface vtable ──────────────────────────────────────────
//
// Index Method
// 0 _reserved
// 1 QueryInterface
// 2 AddRef
// 3 Release
// 4 IsExclusiveAccessAvailable
// 5 AddCallbackDispatcherToRunLoop
// 6 RemoveCallbackDispatcherFromRunLoop
// 7 ObtainExclusiveAccess
// 8 ReleaseExclusiveAccess
// 9 CreateSCSITask
const VTIDX_OBTAIN_EXCLUSIVE: usize = 7;
const VTIDX_RELEASE_EXCLUSIVE: usize = 8;
const VTIDX_CREATE_TASK: usize = 9;
// ── SCSITaskInterface vtable ────────────────────────────────────────────────
//
// Index Method
// 0 _reserved
// 1 QueryInterface
// 2 AddRef
// 3 Release
// 4 IsTaskActive
// 5 SetTaskAttribute
// 6 GetTaskAttribute
// 7 GetTaskState
// 8 SetCommandDescriptorBlock
// 9 GetCommandDescriptorBlockSize
// 10 GetCommandDescriptorBlock
// 11 SetScatterGatherEntries
// 12 SetTimeoutDuration
// 13 GetTimeoutDuration
// 14 SetTaskCompletionCallback
// 15 ExecuteTaskSync
// 16 ExecuteTaskAsync
// 17 AbortTask
// 18 GetSCSIServiceResponse
// 19 GetTaskStatus
// 20 GetRealizedDataTransferCount
// 21 GetAutoSenseData
const VTIDX_SET_CDB: usize = 8;
const VTIDX_SET_SG: usize = 11;
const VTIDX_SET_TIMEOUT: usize = 12;
const VTIDX_EXECUTE_SYNC: usize = 15;
// ── Transport implementation ────────────────────────────────────────────────
pub struct MacScsiTransport {
device_iface: ComRef,
exclusive: bool,
}
impl MacScsiTransport {
pub fn open(device: &Path) -> Result<Self> {
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
// Strip /dev/ prefix to get BSD name (e.g. "disk2")
let bsd_name = if let Some(rest) = dev_str.strip_prefix("/dev/r") {
rest
} else if let Some(rest) = dev_str.strip_prefix("/dev/") {
rest
} else {
dev_str
};
let service = find_scsi_service(bsd_name)?;
// Create IOKit plugin for the MMC device
let mut plugin: ComRef = std::ptr::null_mut();
let mut score: i32 = 0;
let kr = unsafe {
IOCreatePlugInInterfaceForService(
service,
&K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID,
&K_IO_CFPLUGIN_INTERFACE_ID,
&mut plugin,
&mut score,
)
};
unsafe { IOObjectRelease(service) };
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: IOKit plugin creation failed (0x{:08x})", dev_str, kr),
});
}
// QueryInterface for SCSITaskDeviceInterface
let mut device_iface: ComRef = std::ptr::null_mut();
let hr = unsafe {
type QiFn = unsafe extern "C" fn(ComRef, *const [u8; 16], *mut ComRef) -> i32;
let qi: QiFn = vtable_fn(plugin, 1);
qi(plugin, &K_IO_SCSI_TASK_DEVICE_INTERFACE_ID, &mut device_iface)
};
com_release(plugin);
if hr != 0 || device_iface.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: SCSITaskDeviceInterface not available", dev_str),
});
}
// Obtain exclusive access
let kr = unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
let f: Fn = vtable_fn(device_iface, VTIDX_OBTAIN_EXCLUSIVE);
f(device_iface)
};
if kr != K_IO_RETURN_SUCCESS {
com_release(device_iface);
return Err(Error::DevicePermission {
path: format!(
"{}: exclusive access denied (0x{:08x}). Try: diskutil unmountDisk {}",
dev_str, kr, dev_str
),
});
}
Ok(MacScsiTransport {
device_iface,
exclusive: true,
})
}
}
impl Drop for MacScsiTransport {
fn drop(&mut self) {
if self.exclusive {
unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
let f: Fn = vtable_fn(self.device_iface, VTIDX_RELEASE_EXCLUSIVE);
f(self.device_iface);
}
}
com_release(self.device_iface);
}
}
impl ScsiTransport for MacScsiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
// Create a SCSI task
let task: ComRef = unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> ComRef;
let f: Fn = vtable_fn(self.device_iface, VTIDX_CREATE_TASK);
f(self.device_iface)
};
if task.is_null() {
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
});
}
// Set CDB
let mut cdb_padded = [0u8; K_MAX_CDB_SIZE];
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);
cdb_padded[..cdb_len].copy_from_slice(&cdb[..cdb_len]);
unsafe {
type Fn = unsafe extern "C" fn(ComRef, *const u8, u8) -> IOReturn;
let f: Fn = vtable_fn(task, VTIDX_SET_CDB);
f(task, cdb_padded.as_ptr(), cdb_len as u8);
}
// Set scatter/gather and transfer direction
let iokit_dir = match direction {
DataDirection::None => K_SCSI_DATA_TRANSFER_NO_DATA,
DataDirection::FromDevice => K_SCSI_DATA_TRANSFER_FROM_TARGET,
DataDirection::ToDevice => K_SCSI_DATA_TRANSFER_TO_TARGET,
};
if direction != DataDirection::None && !data.is_empty() {
let sg = SCSITaskSGElement {
address: data.as_mut_ptr() as u64,
length: data.len() as u64,
};
unsafe {
type Fn = unsafe extern "C" fn(
ComRef, *const SCSITaskSGElement, u8, u64, u8,
) -> IOReturn;
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
f(task, &sg, 1, data.len() as u64, iokit_dir);
}
} else {
unsafe {
type Fn = unsafe extern "C" fn(
ComRef, *const SCSITaskSGElement, u8, u64, u8,
) -> IOReturn;
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
f(task, std::ptr::null(), 0, 0, K_SCSI_DATA_TRANSFER_NO_DATA);
}
}
// Set timeout (IOKit SCSITask takes milliseconds)
unsafe {
type Fn = unsafe extern "C" fn(ComRef, u32);
let f: Fn = vtable_fn(task, VTIDX_SET_TIMEOUT);
f(task, timeout_ms);
}
// Execute synchronously
let mut sense = [0u8; K_SENSE_DATA_SIZE];
let mut task_status: u8 = 0;
let mut realized_count: u64 = 0;
let kr = unsafe {
type Fn = unsafe extern "C" fn(
ComRef, *mut u8, *mut u8, *mut u64,
) -> IOReturn;
let f: Fn = vtable_fn(task, VTIDX_EXECUTE_SYNC);
f(task, sense.as_mut_ptr(), &mut task_status, &mut realized_count)
};
com_release(task);
if kr != K_IO_RETURN_SUCCESS {
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
});
}
if task_status != K_SCSI_TASK_STATUS_GOOD {
let sense_key = if sense[2] != 0 { sense[2] & 0x0F } else { 0 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: task_status,
sense_key,
});
}
Ok(ScsiResult {
status: task_status,
bytes_transferred: realized_count as usize,
sense,
})
}
}
// ── IOKit service discovery ─────────────────────────────────────────────────
/// BSD name → IOKit service for the SCSI device.
///
/// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub.
fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
let mut master: MachPort = 0;
let kr = unsafe { IOMasterPort(0, &mut master) };
if kr != K_IO_RETURN_SUCCESS {
return Err(Error::DeviceNotFound {
path: format!("{}: IOMasterPort failed", bsd_name),
});
}
// IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name }
let mut bsd_c = bsd_name.as_bytes().to_vec();
bsd_c.push(0);
let matching = unsafe { IOBSDNameMatching(master, 0, bsd_c.as_ptr()) };
if matching.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: IOBSDNameMatching failed", bsd_name),
});
}
// Find the single IOMedia service (consumes the matching dict)
let media = unsafe { IOServiceGetMatchingService(master, matching) };
if media == 0 {
return Err(Error::DeviceNotFound {
path: format!("{}: no IOMedia found", bsd_name),
});
}
// Walk up the IOService plane to find the authoring device.
// The chain is typically:
// IOMedia → IOPartitionScheme → IOMedia → IOBlockStorageDriver
// → IOSCSIPeripheralDeviceNub (this is what we want)
//
// We walk up until we find a service that IOCreatePlugInInterfaceForService
// accepts with kIOMMCDeviceUserClientTypeID, or until we hit the root.
let service = walk_to_authoring_device(media);
unsafe { IOObjectRelease(media) };
service.ok_or_else(|| Error::DeviceNotFound {
path: format!("{}: no SCSI authoring device in IORegistry", bsd_name),
})
}
/// Walk up the IOService plane from an IOMedia to the SCSI authoring device.
fn walk_to_authoring_device(start: IOObject) -> Option<IOObject> {
let mut current = start;
// Retain start so we can release uniformly in the loop
// (IORegistryEntryGetParentEntry retains the parent for us)
// Target class names for authoring devices
let target_classes: &[&[u8]] = &[
b"IOSCSIPeripheralDeviceNub\0",
b"IOBDBlockStorageDevice\0",
b"IODVDBlockStorageDevice\0",
b"IOCDBlockStorageDevice\0",
b"IOBlockStorageDevice\0",
];
// Walk up to 10 levels (more than enough)
for _ in 0..10 {
let mut parent: IOObject = 0;
let kr = unsafe {
IORegistryEntryGetParentEntry(current, b"IOService\0".as_ptr(), &mut parent)
};
if current != start {
unsafe { IOObjectRelease(current) };
}
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
return None;
}
// Check if this parent matches any of our target classes
for class in target_classes {
if unsafe { IOObjectConformsTo(parent, class.as_ptr()) } != 0 {
return Some(parent);
}
}
current = parent;
}
if current != start {
unsafe { IOObjectRelease(current) };
}
None
}
+142
View File
@@ -0,0 +1,142 @@
//! SCSI/MMC command interface.
//!
//! Platform backends are in separate files:
//! - `linux.rs` — SG_IO ioctl
//! - `macos.rs` — IOKit SCSITaskDeviceInterface
//! - `windows.rs` — SPTI (planned)
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use std::path::Path;
// ── SCSI opcodes (SPC-4, MMC-6) ────────────────────────────────────────────
pub const SCSI_INQUIRY: u8 = 0x12;
pub const SCSI_READ_CAPACITY: u8 = 0x25;
pub const SCSI_READ_10: u8 = 0x28;
pub const SCSI_READ_BUFFER: u8 = 0x3C;
pub const SCSI_READ_TOC: u8 = 0x43;
pub const SCSI_GET_CONFIGURATION: u8 = 0x46;
pub const SCSI_SET_CD_SPEED: u8 = 0xBB;
pub const SCSI_SEND_KEY: u8 = 0xA3;
pub const SCSI_REPORT_KEY: u8 = 0xA4;
pub const SCSI_READ_12: u8 = 0xA8;
pub const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
/// AACS key class for REPORT KEY / SEND KEY commands.
pub const AACS_KEY_CLASS: u8 = 0x02;
// ── Types ───────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DataDirection {
None,
FromDevice,
ToDevice,
}
#[derive(Debug)]
pub struct ScsiResult {
pub status: u8,
pub bytes_transferred: usize,
pub sense: [u8; 32],
}
/// Low-level SCSI transport — one implementation per platform.
pub trait ScsiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
}
// ── Platform-agnostic open ──────────────────────────────────────────────────
/// Open a SCSI transport for the given device path.
/// Selects the right backend for the current platform.
pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
#[cfg(target_os = "linux")]
{ Ok(Box::new(linux::SgIoTransport::open(device)?)) }
#[cfg(target_os = "macos")]
{ Ok(Box::new(macos::MacScsiTransport::open(device)?)) }
#[cfg(target_os = "windows")]
{ Err(Error::DeviceNotFound { path: format!("{}: Windows not yet supported", device.display()) }) }
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{ Err(Error::DeviceNotFound { path: format!("{}: unsupported platform", device.display()) }) }
}
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
/// SCSI INQUIRY response.
#[derive(Debug, Clone)]
pub struct InquiryResult {
pub vendor_id: String,
pub model: String,
pub firmware: String,
pub raw: Vec<u8>,
}
/// Send INQUIRY and parse standard response fields.
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
let cdb = [SCSI_INQUIRY, 0x00, 0x00, 0x00, 0x60, 0x00];
let mut buf = [0u8; 96];
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(InquiryResult {
vendor_id: String::from_utf8_lossy(&buf[8..16]).trim().to_string(),
model: String::from_utf8_lossy(&buf[16..32]).trim().to_string(),
firmware: String::from_utf8_lossy(&buf[32..36]).trim().to_string(),
raw: buf.to_vec(),
})
}
/// Send GET CONFIGURATION for feature 0x010C (Firmware Information).
pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
let cdb = [SCSI_GET_CONFIGURATION, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
let mut buf = [0u8; 16];
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(buf.to_vec())
}
/// Build a READ BUFFER CDB.
pub fn build_read_buffer(mode: u8, buffer_id: u8, offset: u32, length: u32) -> [u8; 10] {
[
SCSI_READ_BUFFER, mode, buffer_id,
(offset >> 16) as u8, (offset >> 8) as u8, offset as u8,
(length >> 16) as u8, (length >> 8) as u8, length as u8,
0x00,
]
}
/// Build a SET CD SPEED CDB.
pub fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
[
SCSI_SET_CD_SPEED, 0x00,
(read_speed >> 8) as u8, read_speed as u8,
0xFF, 0xFF,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]
}
/// Build a READ(10) CDB with the raw read flag.
pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
[
SCSI_READ_10, 0x08,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00,
(count >> 8) as u8, count as u8,
0x00,
]
}
+1 -3
View File
@@ -65,9 +65,7 @@ impl DriveSpeed {
22_001..=31_000 => DriveSpeed::BD6x,
31_001..=40_000 => DriveSpeed::BD8x,
40_001..=49_000 => DriveSpeed::BD10x,
49_001..=0xFFFE => DriveSpeed::BD12x,
0xFFFF => DriveSpeed::Max,
_ => DriveSpeed::Max,
49_001..=u16::MAX => DriveSpeed::BD12x,
}
}
+213 -20
View File
@@ -26,11 +26,15 @@ use crate::drive::DriveSession;
pub struct UdfFs {
/// Root directory with full tree
pub root: DirEntry,
/// UDF Volume Identifier from Primary Volume Descriptor
pub volume_id: String,
/// Physical partition start (absolute sector)
partition_start: u32,
/// Metadata partition start (absolute sector)
/// For UDF 2.50 discs, all file/directory references use metadata-relative LBAs
metadata_start: u32,
/// Metadata partition size in sectors
metadata_sectors: u32,
}
/// A directory or file entry.
@@ -47,6 +51,12 @@ pub struct DirEntry {
}
impl UdfFs {
/// Physical partition start sector.
pub fn partition_start(&self) -> u32 { self.partition_start }
/// Metadata partition start sector.
pub fn metadata_start(&self) -> u32 { self.metadata_start }
/// Find a directory by path (e.g. "/BDMV/PLAYLIST").
/// Path matching is case-insensitive.
pub fn find_dir(&self, path: &str) -> Option<&DirEntry> {
@@ -62,6 +72,28 @@ 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::DiscError {
detail: format!("directory not found: {}", part),
})?;
}
let filename = parts.last().unwrap();
let entry = current.entries.iter().find(|e| {
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
}).ok_or_else(|| Error::DiscError {
detail: format!("file not found: {}", path),
})?;
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;
@@ -87,9 +119,11 @@ impl UdfFs {
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
// Read file data sector by sector
// File DATA is in the physical partition (partition_start + lba),
// NOT the metadata partition. ICBs are in metadata, data is in physical.
let sector_count = ((data_len as u64 + 2047) / 2048) as u32;
let mut data = vec![0u8; (sector_count as usize) * 2048];
let abs_start = self.meta_to_abs(data_lba);
let abs_start = self.partition_start + data_lba;
for i in 0..sector_count {
let offset = (i as usize) * 2048;
@@ -100,6 +134,59 @@ impl UdfFs {
Ok(data)
}
/// Collect all sector ranges needed for disc-info and AACS.
///
/// Returns a list of (start_lba, sector_count) ranges covering:
/// - UDF structure (AVDP, VDS, metadata partition, directories)
/// - BDMV/PLAYLIST/*.mpls, CLIPINF/*.clpi, JAR/*, META/*, *.bdmv
/// - AACS/* (Content*.cer, Unit_Key_RO.inf, CPSUnit*.cci)
///
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
/// MKB_RO.inf, ContentHash*, ContentRevocation*
pub fn metadata_sector_ranges(&self, session: &mut DriveSession) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure: sector 0 through end of metadata partition
// Covers AVDP, VDS, partition descriptor, metadata ICB, FSD, all directories
let meta_end = self.metadata_start + self.metadata_sectors;
ranges.push((0, meta_end));
// Walk tree, collect ranges for each metadata file
self.collect_file_ranges(session, &self.root, &mut ranges)?;
// Merge overlapping/adjacent ranges and sort
ranges.sort_by_key(|r| r.0);
let merged = merge_ranges(&ranges);
Ok(merged)
}
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
for child in &entry.entries {
if child.is_dir {
// Only skip STREAM — those are the multi-GB video files
if child.name.eq_ignore_ascii_case("STREAM") {
continue;
}
self.collect_file_ranges(session, child, ranges)?;
} else {
// Include the ICB sector itself (in metadata partition)
ranges.push((self.meta_to_abs(child.meta_lba), 1));
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
if child.size > 50_000_000 {
continue;
}
if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
let abs_start = self.partition_start + data_lba;
let sector_count = (data_len + 2047) / 2048;
ranges.push((abs_start, sector_count));
}
}
}
Ok(())
}
/// Convert a metadata-partition-relative LBA to an absolute sector number.
fn meta_to_abs(&self, meta_lba: u32) -> u32 {
self.metadata_start + meta_lba
@@ -107,45 +194,94 @@ 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::DiscError {
detail: "no allocation descriptors in ICB".into(),
})
}
/// 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),
}),
};
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 > 2048 {
break; // TODO: follow Allocation Extent Descriptors (tag 258) for overflow
}
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::DiscError {
detail: format!("directory not found: {}", part),
})?;
}
let filename = parts.last().unwrap();
let entry = current.entries.iter().find(|e| {
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
}).ok_or_else(|| Error::DiscError {
detail: format!("file not found: {}", path),
})?;
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)
}
}
@@ -179,6 +315,8 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let mut partition_start: u32 = 0;
let mut num_partition_maps: u32 = 0;
let mut lvd_sector: Option<u32> = None;
let mut volume_id = String::new();
let mut metadata_size_bytes: u32 = 0;
for i in 32..64 {
let mut desc = [0u8; 2048];
@@ -186,6 +324,10 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
match desc_tag {
// Primary Volume Descriptor — volume identifier at offset 24, 32-byte d-string
1 => {
volume_id = parse_dstring(&desc[24..56]);
}
// Partition Descriptor — tells us where the physical partition starts
5 => {
partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]);
@@ -219,7 +361,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
// Parse partition maps starting at offset 440
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
let pm1_type = lvd[440]; // First map type
let _pm1_type = lvd[440]; // First map type
let pm1_len = lvd[441] as usize;
if pm1_len > 0 && 440 + pm1_len < 2048 {
@@ -239,8 +381,9 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let l_ea = u32::from_le_bytes([meta_icb[208], meta_icb[209],
meta_icb[210], meta_icb[211]]) as usize;
let ad_off = 216 + l_ea;
let _ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
let ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
meta_icb[ad_off + 2], meta_icb[ad_off + 3]]) & 0x3FFFFFFF;
metadata_size_bytes = ad_len;
let ad_pos = u32::from_le_bytes([meta_icb[ad_off + 4], meta_icb[ad_off + 5],
meta_icb[ad_off + 6], meta_icb[ad_off + 7]]);
// Metadata content starts at partition_start + ad_pos
@@ -279,10 +422,14 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
// Step 5: Read root directory and build file tree
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
Ok(UdfFs {
root,
volume_id,
partition_start,
metadata_start,
metadata_sectors,
})
}
@@ -454,6 +601,52 @@ fn parse_udf_name(data: &[u8]) -> String {
}
}
/// Merge overlapping or adjacent (start, count) ranges.
fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
if ranges.is_empty() { return Vec::new(); }
let mut result = vec![ranges[0]];
for &(start, count) in &ranges[1..] {
let last = result.last_mut().unwrap();
let last_end = last.0 + last.1;
if start <= last_end + 1 {
// Overlapping or adjacent — extend
let new_end = (start + count).max(last_end);
last.1 = new_end - last.0;
} else {
result.push((start, count));
}
}
result
}
/// Parse a UDF d-string (fixed-length field with length byte at the end).
/// Used for Volume Identifier and other UDF descriptor strings.
/// The first byte of content is a compression ID: 8 = ASCII, 16 = UTF-16BE.
fn parse_dstring(data: &[u8]) -> String {
if data.is_empty() { return String::new(); }
let len = *data.last().unwrap() as usize;
if len == 0 || len > data.len() { return String::new(); }
let content = &data[..len];
if content.is_empty() { return String::new(); }
match content[0] {
8 => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
16 => {
let mut s = String::new();
let chars = &content[1..];
for i in (0..chars.len()).step_by(2) {
if i + 1 < chars.len() {
let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
if c != 0 {
if let Some(ch) = char::from_u32(c as u32) { s.push(ch); }
}
}
}
s.trim().to_string()
}
_ => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
}
}
/// Read a single 2048-byte sector from the drive.
/// Uses standard READ(10) — no unlock required.
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {