Compare commits

...
50 Commits
Author SHA1 Message Date
MattJackson 9aaddaa9b8 v0.11.17: adaptive batch sizer — no per-sector descent
Replace read_with_binary_search + 3×5s light recovery with an adaptive
sizer that shrinks on failure (halve, 3-aligned ≥6) and probes back up
after 100 MiB (51,200 sectors) of clean reads. Descent cost is paid
once per bad region, not once per bad sector.

Emit BatchSizeChanged { new_size, reason } on shrink and probe-up.
Remove BinarySearch event — no longer produced.

Side fix: scsi/macos.rs one-liner for manual_c_str_literals clippy
lint that surfaced on a newer toolchain.
2026-04-23 20:24:40 -07:00
Matt Jackson 40ec1e1eba v0.11.16: API cleanup — one method per action 2026-04-21 19:17:50 +00:00
Matt Jackson 8843833ea7 v0.11.15: lint cleanup — fmt + clippy clean 2026-04-21 18:52:55 +00:00
Matt Jackson afae37c8c3 fix: iso_dump example missing recovery arg 2026-04-21 18:41:41 +00:00
Matt Jackson d8089ec491 v0.11.14: audit fixes — trailing sectors, verify stop, SCSI sense, O_CLOEXEC
Fix trailing sectors dropped at extent boundaries when sector_count % 3 != 0.
Add verify_title stop support via progress callback returning bool.
Add O_CLOEXEC on all SCSI fd opens to prevent leak to child processes.
Fix SCSI sense descriptor format detection (0x72/0x73 vs 0x70/0x71).
2026-04-21 18:40:04 +00:00
Matt Jackson ce8132d181 Update docs: async sg transport, Drive::read recovery phases 2026-04-21 18:01:48 +00:00
Matt Jackson 71e1f57364 Async SG_IO: enforceable timeouts via write/poll/read
Replace blocking ioctl(SG_IO) with the sg driver's async interface.
Commands are submitted via write(), waited on via poll() with a hard
wall-clock timeout, and completed via read(). If poll() times out,
the fd is abandoned and a fresh one opened — the kernel can no longer
hold us hostage during USB error recovery.

- write() submits command, returns immediately
- poll() enforces exact timeout (EINTR-safe with deadline tracking)
- read() retrieves result + copies data to caller's buffer
- On timeout: old fd closed in background thread, new fd opened
- No SG_FLAG_DIRECT_IO — kernel buffers for safe timeout abandonment
- Store device_path for fd reopen after timeout
- Drop guards fd=-1 (abandoned fd)
2026-04-21 17:57:58 +00:00
Matt Jackson d8092ee093 Clean API: merge read() and read_fast() into read(recovery: bool) 2026-04-21 03:27:17 +00:00
Matt Jackson dd49d55b11 v0.11.13: all rip reads use fast timeout, no full recovery in read path 2026-04-21 02:16:49 +00:00
Matt Jackson 6ba958fe8a Fix: initial batch read uses fast read, not full recovery 2026-04-21 02:02:13 +00:00
Matt Jackson f30b958408 v0.11.12: halt, events, light recovery 2026-04-21 00:25:44 +00:00
Matt Jackson 6971f00be2 Drive halt flag, sector events, binary search light recovery (3x5s) 2026-04-21 00:18:46 +00:00
Matt Jackson ad86f92d85 v0.11.11: binary search error recovery in fill_extents 2026-04-20 21:51:46 +00:00
Matt Jackson cce1be29b2 v0.11.10: skip_errors, read_sectors_recover API 2026-04-20 15:27:56 +00:00
Matt Jackson 081ad4f619 DiscStream skip_errors, read_sectors_recover(recovery) API 2026-04-20 03:38:32 +00:00
Matt Jackson 43ecad2d43 Clean API: read_sectors_recover(recovery: bool) replaces read_sectors_fast 2026-04-20 01:12:59 +00:00
Matt Jackson 8870efa77b v0.11.9: fast verify reads — 5s timeout, no recovery loop 2026-04-20 01:01:44 +00:00
Matt Jackson a693ef965b v0.11.8: disc verify module 2026-04-20 00:03:26 +00:00
Matt Jackson 0596307c19 Add verify module: sector-by-sector disc health check 2026-04-20 00:01:02 +00:00
Matt Jackson 18c6365e08 Improve label generator: proper video labels, all resolutions, no false Atmos claims 2026-04-19 17:50:17 +00:00
Matt Jackson 981f30b1b0 Move label generation to labels system — fill_defaults() for all stream types 2026-04-19 17:23:50 +00:00
Matt Jackson 04086d73a0 Generate audio track labels when disc doesn't provide them 2026-04-19 16:34:35 +00:00
Matt Jackson 20e404e59a README: fix dep version 0.10 → 0.11, update share command 2026-04-19 04:57:55 +00:00
Matt Jackson 1bea8eb650 v0.11.7: TrueHD parser rewrite — 12-bit length, AC-3 skip, cross-PES buffering 2026-04-19 01:36:56 +00:00
Matt Jackson 0165f18fad v0.11.6: fix TrueHD BD-TS header corruption 2026-04-18 19:27:12 +00:00
Matt Jackson ca3914d537 Fix TrueHD: strip BD-TS access unit header before muxing to MKV 2026-04-18 19:26:14 +00:00
Matt Jackson 80fcffd190 v0.11.5: MKV container fixes — timestamps, frame rate, HDR, chapters, disposition 2026-04-18 16:29:21 +00:00
Matt Jackson 7437af39c8 v0.11.3: unified versioning across all repos 2026-04-18 15:01:53 +00:00
Matt Jackson bcd6925170 v0.10.10: fix dual-layer disc rips, propagate read errors
Bump version, update CHANGELOG and FEATURES for 0.10.8-0.10.10.
2026-04-18 02:10:20 +00:00
Matt Jackson fba1eb189c Fix truncated rips on dual-layer discs, propagate read errors
Use UDF file_extents() to read actual allocation descriptors instead
of assuming m2ts files are contiguous from file_start_lba. Dual-layer
UHD discs split large files across 70+ extents (~1 GB each) — the old
code created one extent from packet count which only covered the first
chunk, causing silent truncation at ~37%.

Also changed fill_extents() to return io::Result<bool> so read errors
propagate instead of being silently treated as EOF.
2026-04-18 02:08:34 +00:00
Matt Jackson a328ad3cd8 Add freemkv keydb path to KEYDB search, fail on encrypted disc without keys 2026-04-17 22:10:56 +00:00
Matt Jackson 9791e60c65 v0.10.8: prefetch all metadata file sectors — scan 2min to 18s on USB 2026-04-17 19:51:32 +00:00
Matt Jackson 8b1ded84cd Document buffered sector reads in UDF docs 2026-04-17 18:41:53 +00:00
Matt Jackson 3e4febe7f5 Remove debug timing from scan pipeline 2026-04-17 18:26:04 +00:00
Matt Jackson aa7afc0c98 Buffer UDF sector reads — eliminates scan hang on USB drives 2026-04-17 18:24:29 +00:00
Matt Jackson 29f9e916d6 DiscStream::new() replaces open_drive/open_iso/from_reader
A stream is a stream. DiscStream::new() takes reader + title + keys +
batch + format — same pattern as every other stream constructor.

Deleted: open_drive(), open_iso(), from_reader() — these were helper
functions that chained multiple operations. Library provides primitives,
callers decide the sequence.

Removed disc:// case from input() — callers use Drive::open() +
Disc::scan() + DiscStream::new() directly for disc sources.
2026-04-17 15:32:48 +00:00
Matt Jackson 30d59063d5 Fix drive discovery in Docker — remove sysfs check 2026-04-16 22:05:25 +00:00
MattJackson 6e958b0f71 v0.10.5: Buffer audio parsers across PES boundaries 2026-04-16 19:55:57 +00:00
MattJackson 911d260695 Buffer audio parsers across PES boundaries
- DTS: buffer with core sync detection + frame size from header
- TrueHD: buffer with unit length field parsing
- Same pattern as AC3 fix: incomplete frames held for next PES
- When PES boundaries align (normal case), buffering is a no-op
2026-04-16 19:02:23 +00:00
MattJackson 59d4eb8854 Fix AC3 parser: buffer across PES boundaries, proper frame sizing
- Add state to Ac3Parser (was stateless, split frames at PES boundaries)
- Buffer leftover bytes from incomplete frames for next PES packet
- Calculate exact AC3 frame size from fscod/frmsizecod table
- Calculate EAC3 frame size from frmsiz field
- Skip invalid frame sizes (0 or >8192)
- Eliminates all AC3 decode errors on BD and UHD output
2026-04-16 18:49:30 +00:00
MattJackson 8820f7a460 Fix cargo fmt formatting 2026-04-16 17:51:45 +00:00
MattJackson 8de99bb8e8 Update README and FEATURES.md for v0.10.4 DVD support 2026-04-16 17:36:41 +00:00
MattJackson 3e5e570573 v0.10.4: CSS decryption, MPEG-2 PS demuxer fixes
Full CSS key hierarchy (bus auth, disc key, title key), correct
descramble cipher, MPEG-2 codec parser routing for DVD PS path,
sequence header extraction with quantizer matrices.
2026-04-16 17:33:29 +00:00
MattJackson f95894bba3 Expand CSS Stevenson crack patterns, scan 50K sectors
- Add padding stream (0xBE) with 0xFF payload patterns
- Add video/audio PES with multiple flag/header combinations
- Add navigation pack system header pattern
- Scan up to 50K consecutive scrambled sectors (was 500 sampled)
2026-04-16 15:15:17 +00:00
MattJackson 597f512eca Fix MPEG-2 PS demuxer: codec parsers, sequence header extraction, descramble
- Route DVD PS packets through codec parsers (was bypassing them)
- Extract MPEG-2 sequence header with quantizer matrices for MKV codec_private
- Calculate exact sequence header size from intra/non-intra matrix flags
- Capture sequence extension (B5) from subsequent PES packets
- Revert TAB1 permutation in descramble (XOR-only is correct)
- Fix CSS roundtrip tests for new descramble behavior
2026-04-16 15:06:35 +00:00
MattJackson 8e907393a3 Route DVD PS demuxer through codec parsers, fix CSS test expectations
- DVD PS path now calls parser.parse() like BD-TS path does
- MPEG-2 sequence headers extracted for codec_private
- Keyframe detection from parser instead of always-true
- Fix CSS roundtrip tests: descramble uses TAB1 permutation, not pure XOR
2026-04-16 04:49:46 +00:00
MattJackson c47b9a9c3c Fix CSS decryption: full key hierarchy, correct cipher tables
- Implement complete CSS key chain: bus auth → disc key → title key
- Add 31 player keys for disc key decryption
- Read disc key via READ DVD STRUCTURE format 0x02
- Read title key via REPORT KEY format 0x04
- Fix CryptKey round 1: use original scratch for term, not modified tmp1
- Fix decrypt_key: use TAB5 for LFSR1 output, TAB4 for LFSR0^invert
- Fix descramble_sector: use TAB5 for LFSR1, TAB4 for LFSR0 (no invert),
  and apply TAB1 permutation to ciphertext before XOR
- Fix title key bus XOR: forward order (bus_key[i]), not reversed
- Two-session auth: disc key and title key need separate AGID sessions
- Fix crack_key: scan across extents for scrambled sectors
- Fix TsDemuxer: dynamic PID table size for DVD PIDs
- Set max read speed after scan for DVD riplock removal
2026-04-16 04:42:42 +00:00
MattJackson d7b9d87075 v0.10.3: CSS drive authentication for DVD ripping 2026-04-16 00:01:48 +00:00
MattJackson e0f40583c4 Fix cargo fmt formatting 2026-04-15 22:32:53 +00:00
MattJackson d06a37d3dc Add 0.10.2 changelog entry 2026-04-15 22:29:33 +00:00
57 changed files with 3478 additions and 1232 deletions
+175
View File
@@ -1,5 +1,180 @@
# Changelog
## 0.11.17 (2026-04-23)
### Adaptive batch sizer in DiscStream — no more per-sector descent
Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full descent (batch → half → quarter → … → single) for every bad sector in a region. On a damaged disc with 600 consecutive bad sectors this took 12+ hours. The new algorithm pays the descent once, remembers the working size, and ramps back up only after a sustained clean streak.
- **`BatchSizeChanged { new_size, reason }` event** — fires on shrink (read failed) and probe-up (clean streak threshold hit). Consumers use this to distinguish a "recovering" rip from a normal one.
- **Removed `BinarySearch` and `SectorRecovered` emissions from DiscStream** — no longer produced by the rip path. `SectorRecovered` still fires from `Drive::read`'s multi-phase recovery (unused by rips today, but kept for scan/other callers).
- **Removed `read_with_binary_search` and the 3×5s light-recovery loop** — no retry loops, no sleeps. One 5s attempt per read. On size-1 failure, skip (zero-fill) or error.
- **Probe-up threshold: 100 MiB (51,200 sectors) of clean reading at current size** before doubling toward preferred. Ramp 1 → preferred on good reading takes ~100 seconds for a typical BD — trivial vs. rip duration, conservative enough that a single lucky sector in a marginal zone can't trigger a premature probe.
- **Bad-region math**: ~600 consecutive bad sectors now complete in ~50 min (600 × 5s) instead of ~12h. The descent is O(log preferred) one time, not per sector.
### macOS
- Fix new clippy lint (`manual_c_str_literals`) in `scsi/macos.rs`.
## 0.11.16 (2026-04-21)
### API cleanup — one method per action
- **SectorReader::read_sectors(lba, count, buf, recovery)** — single method with `recovery: bool`. Removes `read_sectors_recover()`.
- **parser_for_codec(codec, codec_data)** — single constructor. Removes `parser_for_codec_with_data()`.
- **DvdSubParser::new(codec_data)** — single constructor. Removes `with_codec_data()`.
- **MkvMuxer::new(writer, tracks, title, duration, chapters)** — single constructor. Removes `new_with_chapters()`.
## 0.11.15 (2026-04-21)
### Lint cleanup
- Fix all `cargo fmt` and `cargo clippy -D warnings` across codebase.
- Remove unused imports, dead code, collapsible if-statements, div_ceil reimplementation.
## 0.11.14 (2026-04-21)
### Audit fixes: read recovery, verify, SCSI
- **Fix: trailing sectors at extent boundaries** — extents with sector_count not divisible by 3 no longer drop 1-2 trailing sectors. decrypt_sectors() safely skips partial AACS units.
- **Fix: verify_title stop support** — progress callback now returns bool. Return false to stop verification early instead of running to completion.
- **Fix: O_CLOEXEC on all SCSI fd opens** — prevents fd leak to child processes.
- **Fix: SCSI sense descriptor format** — correctly detect response code 0x72/0x73 (descriptor format) and extract sense key from byte 1 instead of byte 2.
- **Fix: DecryptFailed on missing unit key** — decrypt_sectors() returns Err(DecryptFailed) instead of silently using a zero key.
## 0.11.13 (2026-04-21)
### Fix: all rip reads use fast timeout
- Initial batch read changed from full Drive::read() recovery to fast 5s timeout. Binary search starts immediately on failure instead of after 10 minutes of retries.
- Max 15 seconds per bad sector (3 x 5s attempts). Max 23 seconds per batch with 1 bad sector.
## 0.11.12 (2026-04-21)
### Drive halt + sector events + light recovery
- **Drive.halt()** — AtomicBool flag checked between retry phases. Max 30s to stop.
- **Drive.on_event()** — callback for ReadError, Retry, SpeedChange, SectorRecovered events.
- **Error::Halted (E6010)** — distinct from DiscRead, indicates intentional stop.
- **Binary search light recovery** — single sectors get 3 attempts x 5s (15s max) instead of full 10-min Drive::read() recovery. Marginal disc zones complete in minutes not hours.
- **DiscStream.on_event()** — BinarySearch, SectorRecovered, SectorSkipped events.
## 0.11.11 (2026-04-20)
### Binary search error recovery
- **fill_extents binary search** — when a batch read fails, binary search to isolate the failing sector(s). Good sectors read in sub-batches at full speed. Only truly bad sectors get individual recovery. 60-sector batch with 1 bad sector: ~5 seconds instead of 10+ minutes.
## 0.11.10 (2026-04-20)
### Skip errors + clean verify API
- **DiscStream.skip_errors** — when true, zero-fills unreadable sectors and continues instead of aborting. Caller sets based on user preference.
- **read_sectors_recover(recovery: bool)** — single API for recovery vs fast reads. Replaces separate read_sectors_fast method.
## 0.11.9 (2026-04-20)
### Fast verify reads
- **read_sectors_fast()** — single-attempt 5s timeout SCSI read for verify. No recovery loop. Bad sectors detected in seconds instead of 10+ minutes.
- **SectorReader trait** — added read_sectors_fast() with default fallback to read_sectors().
## 0.11.8 (2026-04-20)
### Disc verify
- **verify::verify_title()** — sector-by-sector health check. Classifies sectors as Good/Slow/Recovered/Bad. Progress callback, chapter mapping, sector ranges.
## 0.11.7 (2026-04-19)
### TrueHD parser rewrite
- **12-bit length mask** — access unit length is lower 12 bits of first 2 bytes, not full 16. Upper 4 bits are parity nibble. Wrong mask caused misaligned frame splits.
- **AC-3 frame skipping** — BD-TS TrueHD PES contains interleaved AC-3 frames (same PID). Parser now detects AC-3 sync word (0x0B77) and skips those frames.
- **Cross-PES buffering** — access units that span PES packet boundaries are correctly reassembled.
- **Per-unit timestamps** — each access unit gets incrementing PTS (1/1200th second apart) instead of all units in one PES sharing the same timestamp.
- **Major sync detection** — keyframe flag set when access unit contains MLP major sync (0xF8726FBA).
- Result: zero TrueHD decode errors on UHD and BD (was ~19 per 30 seconds).
## 0.11.6 (2026-04-18)
### TrueHD fix (incomplete)
- Initial attempt at TrueHD header stripping — wrong approach, superseded by 0.11.7.
## 0.11.5 (2026-04-18)
### MKV container fixes — Jellyfin/player compatibility
- **Timestamp normalization** — MKV and M2TS output starts at 0.000s instead of raw disc PTS offset. Fixes playback failures in Jellyfin and other players.
- **DefaultDuration** — correct frame rate written to MKV track header. Fixes wrong avg_frame_rate (was 293/12, now 24000/1001).
- **HDR Colour metadata** — MatrixCoefficients, TransferCharacteristics, Primaries, Range written to MKV video track. Enables HDR tone mapping in players.
- **DisplayWidth/DisplayHeight** — aspect ratio fields in MKV video track.
- **Chapters (Blu-ray)** — accept mark_type 0 as chapter entry (was filtering to type 1 only, which no disc uses).
- **Chapters (DVD)** — extract chapter timestamps from PGC program map + cell durations.
- **Default disposition** — only first video and first audio track marked default. Fixes wrong auto-selection in players.
## 0.11.3 (2026-04-18)
### Unified versioning
- All freemkv repos now share the same version number. No functional changes from 0.10.10.
## 0.10.10 (2026-04-18)
### Dual-layer disc fix
- **UDF extent allocation** — use actual UDF allocation descriptors (`file_extents()`) instead of assuming m2ts files are contiguous from `file_start_lba`. Dual-layer UHD discs split large files across many extents (~1 GB each). The old single-extent assumption truncated rips at ~37% on affected discs.
- **Read error propagation** — `fill_extents()` returns `io::Result<bool>` so SCSI read errors propagate to the caller instead of being silently treated as EOF.
## 0.10.9 (2026-04-17)
### Fast disc identification
- **Disc::identify()** — reads UDF filesystem only (name, format, layers, encrypted). ~3s on USB vs 18s for full scan. No AACS handshake or playlist parsing.
- **KEYDB path fix** — added `~/.config/freemkv/keydb.cfg` to search paths. Fixes silent rip hang when KEYDB exists but isn't found by `resolve_keydb()`.
## 0.10.8 (2026-04-17)
### Buffered UDF reads
- **BufferedSectorReader** — prefetches batch sectors on single-sector reads. USB drives have ~500ms per SCSI command; this eliminates scan hangs.
- **Metadata partition pre-read** — loads entire UDF metadata partition into memory after initial parse.
- Scan time reduced from 10+ minutes to ~18 seconds on USB.
## 0.10.7 (2026-04-17)
### DiscStream::new()
- Replaced open_drive(), open_iso(), from_reader() with single new() constructor
- Stream accepts ContentFormat and sets up demuxer internally
- Removed disc:// case from input() — callers use primitives directly
## 0.10.6 (2026-04-16)
### Docker compatibility
- **Drive discovery** — removed sysfs check that blocked detection inside Docker containers. Device nodes are sufficient; INQUIRY command validates the device is an optical drive.
## 0.10.5 (2026-04-16)
### Audio parser buffering
- **AC3** — buffer across PES boundaries with frame size from fscod/frmsizecod table. Eliminates all AC3 decode errors on BD and UHD.
- **DTS** — buffer with core sync detection + frame size from header. DTS-HD extension frames handled correctly.
- **TrueHD** — buffer with unit length field parsing. Incomplete units held for next PES.
- All audio parsers now emit complete frames only. When PES boundaries align (normal case), buffering is a no-op.
## 0.10.4 (2026-04-16)
### CSS decryption — full key hierarchy
- **Bus auth → disc key → title key** — complete CSS key chain. Bus authentication with CSSCryptKey challenge-response, disc key decryption using 31 player keys via READ DVD STRUCTURE, title key extraction via REPORT KEY format 0x04.
- **CSS descramble cipher** — correct LFSR keystream generation with TAB5 for LFSR1 output and TAB4 for LFSR0 output. Per-sector key derivation from title key XOR sector seed.
- **Stevenson plaintext attack** — expanded pattern set (padding, video, audio, nav pack headers), scans up to 50K scrambled sectors for ISO key recovery.
- **Disc::copy() CSS decrypt** — sector-level decryption during disc→ISO copy produces clean ISOs with zero scramble flags.
### MPEG-2 PS demuxer fixes
- **DVD PS path routes through codec parsers** — was bypassing parser.parse(), producing raw PES frames without codec_private extraction or keyframe detection.
- **MPEG-2 sequence header extraction** — calculates exact header size including quantizer matrices (intra/non-intra flags), captures sequence extension from subsequent PES packets.
- **TsDemuxer dynamic PID table** — Vec instead of fixed [i16; 8192] for DVD PIDs that may exceed 8192.
## 0.10.3 (2026-04-16)
### DVD CSS authentication
- **CSS drive authentication** — full SCSI REPORT KEY / SEND KEY handshake with 6-round substitution-permutation cipher (CSSCryptKey). Brute-forces variant from 32 possibilities. Drive serves scrambled sectors after auth completes.
- **CSS auth runs before scan** — chicken-and-egg fix: auth must happen before reading VOB sectors for title key cracking, not after.
- **Remove debug output** — strip temporary eprintln from drive reads and CSS auth.
## 0.10.2 (2026-04-15)
### Fixes
- **Disc::copy() batch overflow** — hardcoded 64-sector batch exceeded BU40N's 60-sector hardware limit, causing every read to fail and trigger 5×30s recovery sleep. Now accepts detected batch size from caller, defaults to 60.
- **IFO PGC parsing** — playback time read from offset 0x04 (correct) instead of 0x02 (nr_programs). Cell BCD time at cell+4 not cell+0. DVD durations now correct.
- **Demuxer flush at EOF** — TS and PS demuxers flushed when source reaches EOF, preventing loss of last PES frame. Applied to DiscStream and M2tsStream.
- **DiscStream demuxer selection** — demuxer set by caller based on content_format (TS for Blu-ray, PS for DVD) instead of unconditionally creating TsDemuxer in from_reader()
- **StdioStream FMKV header** — writes/reads metadata header for roundtrip compatibility through stdio pipes
## 0.10.1 (2026-04-15)
### Architecture: streams are PES, disc.copy() for sector dumps
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.10.2"
version = "0.11.17"
edition = "2021"
rust-version = "1.86"
license = "AGPL-3.0-only"
+8 -4
View File
@@ -1,6 +1,6 @@
# libfreemkv — Feature List
## v0.7.1 (current)
## v0.10.10 (current)
### Done
- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject
@@ -11,13 +11,18 @@
- [x] CLPI clip info parser (EP map, sector extents)
- [x] AACS 1.0 decryption (4 VUK paths: KEYDB, media key, processing key, device key)
- [x] AACS 2.0 SCSI handshake (P-256/SHA-256 ECDH, bus decryption, read data key)
- [x] DVD IFO parser (VMG, VTS, PGC chains, cell addresses)
- [x] DVD CSS decryption (bus auth, disc key via player keys, title key, sector descramble)
- [x] CSS Stevenson plaintext attack for ISO key recovery
- [x] MPEG-2 PS demuxer (DVD Program Stream with PES extraction)
- [x] MPEG-2 video codec parser (sequence header, quantizer matrices, keyframe detection)
- [x] KEYDB.cfg download, verify, save (raw TCP, zero HTTP deps)
- [x] Content reading with adaptive batch sizing, error recovery, 12+ MB/s
- [x] Stream labels: 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
- [x] MKV muxer: 8 codec parsers (H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1, LPCM)
- [x] MKV muxer: 14 codec parsers (H.264, HEVC, MPEG-2, AC-3, DTS, TrueHD, PGS, DVD Sub, VC-1, LPCM, +4 more)
- [x] SectorReader trait: decouples disc scanning from SCSI
- [x] 7 stream types: Disc, ISO, MKV, M2TS, Network, Stdio, Null
- [x] IOStream trait with URL-based resolver (scheme://path)
- [x] PES pipeline with unified Stream trait (any source → any dest)
- [x] FMKV metadata header for M2TS and network streams
- [x] Numeric error codes only (no English text in library)
- [x] Event system for progress callbacks
@@ -25,6 +30,5 @@
### Planned
- [ ] Windows testing on real hardware
- [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C)
- [ ] DVD CSS decryption
- [ ] TranscodeStream (ffmpeg integration)
- [ ] ISO write with BD-compliant UDF structure
+3 -3
View File
@@ -4,7 +4,7 @@
# libfreemkv
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.
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
**12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust.
@@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
```toml
[dependencies]
libfreemkv = "0.10"
libfreemkv = "0.11"
```
## Quick Start
@@ -134,7 +134,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
## Contributing
Run `freemkv drive-info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
## License
+15 -4
View File
@@ -34,8 +34,10 @@ fn main() {
let file = std::fs::File::create("/dev/null").unwrap();
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
eprintln!("Reading 1000 batches ({:.1} MB) with write + progress...",
1000.0 * batch as f64 * 2048.0 / 1_048_576.0);
eprintln!(
"Reading 1000 batches ({:.1} MB) with write + progress...",
1000.0 * batch as f64 * 2048.0 / 1_048_576.0
);
let start = Instant::now();
let mut ok = 0u32;
@@ -51,7 +53,9 @@ fn main() {
}
Err(e) => {
fail += 1;
if fail <= 5 { eprintln!(" FAIL LBA {}: {}", lba, e); }
if fail <= 5 {
eprintln!(" FAIL LBA {}: {}", lba, e);
}
buf.fill(0);
writer.write_all(&buf).unwrap();
}
@@ -67,5 +71,12 @@ fn main() {
let elapsed = start.elapsed().as_secs_f64();
let mb = ok as f64 * batch as f64 * 2048.0 / 1_048_576.0;
eprintln!("\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s", ok, fail, mb, elapsed, mb / elapsed);
eprintln!(
"\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s",
ok,
fail,
mb,
elapsed,
mb / elapsed
);
}
+3 -3
View File
@@ -42,7 +42,7 @@ libfreemkv (lib.rs)
│
├── Drive Access
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
│ ├── scsi ScsiTransport trait + platform backends (SG_IO, IOKit, SPTI)
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
│ ├── platform/ Platform trait — per-chipset command handlers
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
│ ├── profile DriveProfile loading, matching, bundled JSON
@@ -83,7 +83,7 @@ libfreemkv (lib.rs)
```
Drive::open(Path::new("/dev/sg4"))
│
├─ scsi::open() Open /dev/sg4 via SG_IO
├─ scsi::open() Open /dev/sg4 (async write/poll/read)
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
├─ profile::find_by_drive_id() Match against bundled profiles
├─ Platform::new() Instantiate chipset driver (Mt1959)
@@ -175,7 +175,7 @@ is baked into the library.
| Platform | Transport | Status |
|----------|-----------|--------|
| Linux | SG_IO ioctl on `/dev/sg*` | Supported |
| Linux | async sg write/poll/read on `/dev/sg*` | Supported |
| macOS | IOKit SCSITask | Supported |
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
+21 -14
View File
@@ -46,14 +46,20 @@ platform driver. The drive is ready for `wait_ready()` and `init()`.
### read() with Recovery
`Drive::read()` is the single read method. On error:
`Drive::read(lba, count, buf, recovery)` is the single read method. The
`recovery` parameter controls whether to attempt multi-phase recovery on
failure or return immediately (used by DiscStream's binary search for
single-sector probes).
1. Set minimum speed immediately
2. Reset device (close/reopen/TUR)
3. Wait 2s for drive to settle
4. Retry at min speed, min batch (3 sectors)
5. If still failing: skip sectors, zero-fill, log
6. Stay at min speed for 500 MB after error (recovery window)
On error with `recovery = true`:
1. **Phase 1 — gentle retry (5 attempts):** set min speed, sleep 30s, retry.
Each retry has a hard wall-clock timeout via async SG_IO.
2. **Phase 2 — fresh start:** close transport, reset device, reopen, reinit.
3. **Phase 3 — gentle retry on fresh connection (5 attempts).**
4. If all fail: return `Err(DiscRead)`. DiscStream handles it (binary search,
skip, zero-fill).
5. Stay at min speed for 500 MB after any recovery (recovery window).
---
@@ -70,8 +76,6 @@ pub trait ScsiTransport: Send {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
fn reset(&mut self, device: &str) -> Result<()>;
}
```
@@ -82,15 +86,18 @@ descriptors or calls ioctls outside of a `ScsiTransport` implementation.
| Platform | Implementation | Device |
|----------|---------------|--------|
| Linux | `SgIoTransport` — `ioctl(fd, SG_IO, &hdr)` | `/dev/sg*` |
| Linux | `SgIoTransport` — async `write`/`poll`/`read` on `/dev/sg*` | `/dev/sg*` |
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
| Windows | `WindowsScsiTransport` — SPTI | `\\.\CdRomN` |
The Linux backend opens with `O_RDWR | O_NONBLOCK`, constructs `sg_io_hdr`,
and returns `ScsiResult` with status, bytes transferred, and sense data.
The Linux backend uses the sg driver's asynchronous interface: `write()` submits
the command, `poll()` waits with an enforceable wall-clock timeout, `read()`
retrieves the result. If `poll()` times out, the fd is abandoned (closed in a
background thread) and a fresh fd opened — the kernel's USB error recovery
cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
sense buffer and returns `Error::ScsiError`.
On non-zero SCSI status, the transport parses sense key from the sense buffer
and returns `Error::ScsiError`.
### CDB Builders
+8
View File
@@ -120,6 +120,14 @@ Each directory read involves two sector reads: one for the ICB, then one or more
`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).
## Buffered Sector Reads
USB optical drives have ~500ms round-trip latency per SCSI command. Since `read_filesystem()` and `read_file()` issue one SCSI READ per sector, a full disc scan can require hundreds of commands -- taking 10+ minutes on USB.
`Disc::scan()` wraps the drive in a `BufferedSectorReader` before reading. On a single-sector read, the buffer prefetches a batch of sectors (sized from the kernel's `max_hw_sectors_kb` for the device) and caches them. Subsequent reads to nearby LBAs return from cache with zero SCSI overhead. After parsing the UDF directory structure, the entire metadata partition is pre-read into the cache, so all ICB lookups during title scanning and encryption resolution are instant.
The buffer is transparent -- `read_filesystem()`, `read_file()`, and all downstream code still call `read_sectors(lba, 1, buf)` as before. The batching happens inside the `SectorReader` implementation.
### UDF Filename Encoding
UDF filenames use a compression ID as the first byte:
+17 -4
View File
@@ -42,7 +42,7 @@ fn main() {
// Tiny yield between reads — test if pacing prevents firmware throttle
std::thread::yield_now();
let t0 = Instant::now();
let ok = drive.read(lba, count, &mut buf[..n]).is_ok();
let ok = drive.read(lba, count, &mut buf[..n], true).is_ok();
let read_ms = t0.elapsed().as_millis();
// Flag slow reads
@@ -50,7 +50,9 @@ fn main() {
eprintln!("\n SLOW READ: LBA {} took {}ms (ok={})", lba, read_ms, ok);
}
if !ok { buf[..n].fill(0); }
if !ok {
buf[..n].fill(0);
}
w.write_all(&buf[..n]).unwrap();
lba += count as u32;
bytes += n as u64;
@@ -60,11 +62,22 @@ fn main() {
let speed = delta as f64 / last.elapsed().as_secs_f64() / 1_048_576.0;
let avg = bytes as f64 / start.elapsed().as_secs_f64() / 1_048_576.0;
let pct = bytes as f64 / (cap as f64 * 2048.0) * 100.0;
eprint!("\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ", pct, lba, speed, avg, bytes as f64 / 1e9);
eprint!(
"\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ",
pct,
lba,
speed,
avg,
bytes as f64 / 1e9
);
last_bytes = bytes;
last = Instant::now();
}
}
w.flush().unwrap();
eprintln!("\nDone: {:.1} GB in {:.0}s", bytes as f64 / 1e9, start.elapsed().as_secs_f64());
eprintln!(
"\nDone: {:.1} GB in {:.0}s",
bytes as f64 / 1e9,
start.elapsed().as_secs_f64()
);
}
+1
View File
@@ -0,0 +1 @@
max_width = 100
+638
View File
@@ -0,0 +1,638 @@
//! CSS drive authentication — full key hierarchy.
//!
//! Protocol:
//! 1. Bus authentication (challenge-response) → bus key
//! 2. Read disc key block (READ DVD STRUCTURE) → XOR with bus key → decrypt with player keys → disc key
//! 3. Read title key (REPORT KEY format 0x04) → XOR with bus key → decrypt with disc key → title key
//!
//! Based on libdvdcss (VideoLAN) and Stevenson 1999 analysis.
use crate::drive::Drive;
use crate::error::{Error, Result};
// ── Player keys (from libdvdcss, Stevenson's PlayerKey cracker) ───────────
const PLAYER_KEYS: [[u8; 5]; 31] = [
[0x01, 0xaf, 0xe3, 0x12, 0x80],
[0x12, 0x11, 0xca, 0x04, 0x3b],
[0x14, 0x0c, 0x9e, 0xd0, 0x09],
[0x14, 0x71, 0x35, 0xba, 0xe2],
[0x1a, 0xa4, 0x33, 0x21, 0xa6],
[0x26, 0xec, 0xc4, 0xa7, 0x4e],
[0x2c, 0xb2, 0xc1, 0x09, 0xee],
[0x2f, 0x25, 0x9e, 0x96, 0xdd],
[0x33, 0x2f, 0x49, 0x6c, 0xe0],
[0x35, 0x5b, 0xc1, 0x31, 0x0f],
[0x36, 0x67, 0xb2, 0xe3, 0x85],
[0x39, 0x3d, 0xf1, 0xf1, 0xbd],
[0x3b, 0x31, 0x34, 0x0d, 0x91],
[0x45, 0xed, 0x28, 0xeb, 0xd3],
[0x48, 0xb7, 0x6c, 0xce, 0x69],
[0x4b, 0x65, 0x0d, 0xc1, 0xee],
[0x4c, 0xbb, 0xf5, 0x5b, 0x23],
[0x51, 0x67, 0x67, 0xc5, 0xe0],
[0x53, 0x94, 0xe1, 0x75, 0xbf],
[0x57, 0x2c, 0x8b, 0x31, 0xae],
[0x63, 0xdb, 0x4c, 0x5b, 0x4a],
[0x7b, 0x1e, 0x5e, 0x2b, 0x57],
[0x85, 0xf3, 0x85, 0xa0, 0xe0],
[0xab, 0x1e, 0xe7, 0x7b, 0x72],
[0xab, 0x36, 0xe3, 0xeb, 0x76],
[0xb1, 0xb8, 0xf9, 0x38, 0x03],
[0xb8, 0x5d, 0xd8, 0x53, 0xbd],
[0xbf, 0x92, 0xc3, 0xb0, 0xe2],
[0xcf, 0x1a, 0xb2, 0xf8, 0x0a],
[0xec, 0xa0, 0xcf, 0xb3, 0xff],
[0xfc, 0x95, 0xa9, 0x87, 0x35],
];
// ── CryptKey tables ───────────────────────────────────────────────────────
const CRYPT_TAB0: [u8; 256] = [
0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF,
0xFB, 0x0E, 0x6D, 0x35, 0xF7, 0x5C, 0x76, 0x12, 0xCE, 0x25, 0x79, 0x29, 0x39, 0x62, 0x08, 0x24,
0xA5, 0x85, 0x7B, 0x56, 0x01, 0x23, 0x68, 0xCF, 0x0A, 0xE2, 0x5A, 0xED, 0x3D, 0x59, 0xB0, 0xA9,
0xB0, 0x2C, 0xF2, 0xB8, 0xEF, 0x32, 0xA9, 0x40, 0x80, 0x71, 0xAF, 0x1E, 0xDE, 0x8F, 0x58, 0x88,
0xB8, 0x3A, 0xD0, 0xFC, 0xC4, 0x1E, 0xB5, 0xA0, 0xBB, 0x3B, 0x0F, 0x01, 0x7E, 0x1F, 0x9F, 0xD9,
0xAA, 0xB8, 0x3D, 0x9D, 0x74, 0x1E, 0x25, 0xDB, 0x37, 0x56, 0x8F, 0x16, 0xBA, 0x49, 0x2B, 0xAC,
0xD0, 0xBD, 0x95, 0x20, 0xBE, 0x7A, 0x28, 0xD0, 0x51, 0x64, 0x63, 0x1C, 0x7F, 0x66, 0x10, 0xBB,
0xC4, 0x56, 0x1A, 0x04, 0x6E, 0x0A, 0xEC, 0x9C, 0xD6, 0xE8, 0x9A, 0x7A, 0xCF, 0x8C, 0xDB, 0xB1,
0xEF, 0x71, 0xDE, 0x31, 0xFF, 0x54, 0x3E, 0x5E, 0x07, 0x69, 0x96, 0xB0, 0xCF, 0xDD, 0x9E, 0x47,
0xC7, 0x96, 0x8F, 0xE4, 0x2B, 0x59, 0xC6, 0xEE, 0xB9, 0x86, 0x9A, 0x64, 0x84, 0x72, 0xE2, 0x5B,
0xA2, 0x96, 0x58, 0x99, 0x50, 0x03, 0xF5, 0x38, 0x4D, 0x02, 0x7D, 0xE7, 0x7D, 0x75, 0xA7, 0xB8,
0x67, 0x87, 0x84, 0x3F, 0x1D, 0x11, 0xE5, 0xFC, 0x1E, 0xD3, 0x83, 0x16, 0xA5, 0x29, 0xF6, 0xC7,
0x15, 0x61, 0x29, 0x1A, 0x43, 0x4F, 0x9B, 0xAF, 0xC5, 0x87, 0x34, 0x6C, 0x0F, 0x3B, 0xA8, 0x1D,
0x45, 0x58, 0x25, 0xDC, 0xA8, 0xA3, 0x3B, 0xD1, 0x79, 0x1B, 0x48, 0xF2, 0xE9, 0x93, 0x1F, 0xFC,
0xDB, 0x2A, 0x90, 0xA9, 0x8A, 0x3D, 0x39, 0x18, 0xA3, 0x8E, 0x58, 0x6C, 0xE0, 0x12, 0xBB, 0x25,
0xCD, 0x71, 0x22, 0xA2, 0x64, 0xC6, 0xE7, 0xFB, 0xAD, 0x94, 0x77, 0x04, 0x9A, 0x39, 0xCF, 0x7C,
];
const CRYPT_TAB1: [u8; 256] = [
0x8C, 0x47, 0xB0, 0xE1, 0xEB, 0xFC, 0xEB, 0x56, 0x10, 0xE5, 0x2C, 0x1A, 0x5D, 0xEF, 0xBE, 0x4F,
0x08, 0x75, 0x97, 0x4B, 0x0E, 0x25, 0x8E, 0x6E, 0x39, 0x5A, 0x87, 0x53, 0xC4, 0x1F, 0xF4, 0x5C,
0x4E, 0xE6, 0x99, 0x30, 0xE0, 0x42, 0x88, 0xAB, 0xE5, 0x85, 0xBC, 0x8F, 0xD8, 0x3C, 0x54, 0xC9,
0x53, 0x47, 0x18, 0xD6, 0x06, 0x5B, 0x41, 0x2C, 0x67, 0x1E, 0x41, 0x74, 0x33, 0xE2, 0xB4, 0xE0,
0x23, 0x29, 0x42, 0xEA, 0x55, 0x0F, 0x25, 0xB4, 0x24, 0x2C, 0x99, 0x13, 0xEB, 0x0A, 0x0B, 0xC9,
0xF9, 0x63, 0x67, 0x43, 0x2D, 0xC7, 0x7D, 0x07, 0x60, 0x89, 0xD1, 0xCC, 0xE7, 0x94, 0x77, 0x74,
0x9B, 0x7E, 0xD7, 0xE6, 0xFF, 0xBB, 0x68, 0x14, 0x1E, 0xA3, 0x25, 0xDE, 0x3A, 0xA3, 0x54, 0x7B,
0x87, 0x9D, 0x50, 0xCA, 0x27, 0xC3, 0xA4, 0x50, 0x91, 0x27, 0xD4, 0xB0, 0x82, 0x41, 0x97, 0x79,
0x94, 0x82, 0xAC, 0xC7, 0x8E, 0xA5, 0x4E, 0xAA, 0x78, 0x9E, 0xE0, 0x42, 0xBA, 0x28, 0xEA, 0xB7,
0x74, 0xAD, 0x35, 0xDA, 0x92, 0x60, 0x7E, 0xD2, 0x0E, 0xB9, 0x24, 0x5E, 0x39, 0x4F, 0x5E, 0x63,
0x09, 0xB5, 0xFA, 0xBF, 0xF1, 0x22, 0x55, 0x1C, 0xE2, 0x25, 0xDB, 0xC5, 0xD8, 0x50, 0x03, 0x98,
0xC4, 0xAC, 0x2E, 0x11, 0xB4, 0x38, 0x4D, 0xD0, 0xB9, 0xFC, 0x2D, 0x3C, 0x08, 0x04, 0x5A, 0xEF,
0xCE, 0x32, 0xFB, 0x4C, 0x92, 0x1E, 0x4B, 0xFB, 0x1A, 0xD0, 0xE2, 0x3E, 0xDA, 0x6E, 0x7C, 0x4D,
0x56, 0xC3, 0x3F, 0x42, 0xB1, 0x3A, 0x23, 0x4D, 0x6E, 0x84, 0x56, 0x68, 0xF4, 0x0E, 0x03, 0x64,
0xD0, 0xA9, 0x92, 0x2F, 0x8B, 0xBC, 0x39, 0x9C, 0xAC, 0x09, 0x5E, 0xEE, 0xE5, 0x97, 0xBF, 0xA5,
0xCE, 0xFA, 0x28, 0x2C, 0x6D, 0x4F, 0xEF, 0x77, 0xAA, 0x1B, 0x79, 0x8E, 0x97, 0xB4, 0xC3, 0xF4,
];
const CRYPT_TAB2: [u8; 256] = [
0xB7, 0x75, 0x81, 0xD5, 0xDC, 0xCA, 0xDE, 0x66, 0x23, 0xDF, 0x15, 0x26, 0x62, 0xD1, 0x83, 0x77,
0xE3, 0x97, 0x76, 0xAF, 0xE9, 0xC3, 0x6B, 0x8E, 0xDA, 0xB0, 0x6E, 0xBF, 0x2B, 0xF1, 0x19, 0xB4,
0x95, 0x34, 0x48, 0xE4, 0x37, 0x94, 0x5D, 0x7B, 0x36, 0x5F, 0x65, 0x53, 0x07, 0xE2, 0x89, 0x11,
0x98, 0x85, 0xD9, 0x12, 0xC1, 0x9D, 0x84, 0xEC, 0xA4, 0xD4, 0x88, 0xB8, 0xFC, 0x2C, 0x79, 0x28,
0xD8, 0xDB, 0xB3, 0x1E, 0xA2, 0xF9, 0xD0, 0x44, 0xD7, 0xD6, 0x60, 0xEF, 0x14, 0xF4, 0xF6, 0x31,
0xD2, 0x41, 0x46, 0x67, 0x0A, 0xE1, 0x58, 0x27, 0x43, 0xA3, 0xF8, 0xE0, 0xC8, 0xBA, 0x5A, 0x5C,
0x80, 0x6C, 0xC6, 0xF2, 0xE8, 0xAD, 0x7D, 0x04, 0x0D, 0xB9, 0x3C, 0xC2, 0x25, 0xBD, 0x49, 0x63,
0x8C, 0x9F, 0x51, 0xCE, 0x20, 0xC5, 0xA1, 0x50, 0x92, 0x2D, 0xDD, 0xBC, 0x8D, 0x4F, 0x9A, 0x71,
0x2F, 0x30, 0x1D, 0x73, 0x39, 0x13, 0xFB, 0x1A, 0xCB, 0x24, 0x59, 0xFE, 0x05, 0x96, 0x57, 0x0F,
0x1F, 0xCF, 0x54, 0xBE, 0xF5, 0x06, 0x1B, 0xB2, 0x6D, 0xD3, 0x4D, 0x32, 0x56, 0x21, 0x33, 0x0B,
0x52, 0xE7, 0xAB, 0xEB, 0xA6, 0x74, 0x00, 0x4C, 0xB1, 0x7F, 0x82, 0x99, 0x87, 0x0E, 0x5E, 0xC0,
0x8F, 0xEE, 0x6F, 0x55, 0xF3, 0x7E, 0x08, 0x90, 0xFA, 0xB6, 0x64, 0x70, 0x47, 0x4A, 0x17, 0xA7,
0xB5, 0x40, 0x8A, 0x38, 0xE5, 0x68, 0x3E, 0x8B, 0x69, 0xAA, 0x9B, 0x42, 0xA5, 0x10, 0x01, 0x35,
0xFD, 0x61, 0x9E, 0xE6, 0x16, 0x9C, 0x86, 0xED, 0xCD, 0x2E, 0xFF, 0xC4, 0x5B, 0xA0, 0xAE, 0xCC,
0x4B, 0x3B, 0x03, 0xBB, 0x1C, 0x2A, 0xAC, 0x0C, 0x3F, 0x93, 0xC7, 0x72, 0x7A, 0x09, 0x22, 0x3D,
0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C,
];
const CRYPT_TAB3: [u8; 288] = [
0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C,
0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F,
0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA,
0x1C, 0xE1, 0x8D, 0x66, 0x4F, 0xF3, 0xDA, 0x92, 0x29, 0xB9, 0xD5, 0xC5, 0x77, 0x47, 0x22, 0x53,
0x14, 0xF7, 0xAF, 0x22, 0x64, 0xDF, 0xC6, 0x72, 0x12, 0xF3, 0x75, 0xDA, 0xD7, 0xD7, 0xE5, 0x02,
0x9E, 0xED, 0xDA, 0xDB, 0x4C, 0x47, 0xCE, 0x91, 0x06, 0x06, 0x6D, 0x55, 0x8B, 0x19, 0xC9, 0xEF,
0x8C, 0x80, 0x1A, 0x0E, 0xEE, 0x4B, 0xAB, 0xF2, 0x08, 0x5C, 0xE9, 0x37, 0x26, 0x5E, 0x9A, 0x90,
0x00, 0xF3, 0x0D, 0xB2, 0xA6, 0xA3, 0xF7, 0x26, 0x17, 0x48, 0x88, 0xC9, 0x0E, 0x2C, 0xC9, 0x02,
0xE7, 0x18, 0x05, 0x4B, 0xF3, 0x39, 0xE1, 0x20, 0x02, 0x0D, 0x40, 0xC7, 0xCA, 0xB9, 0x48, 0x30,
0x57, 0x67, 0xCC, 0x06, 0xBF, 0xAC, 0x81, 0x08, 0x24, 0x7A, 0xD4, 0x8B, 0x19, 0x8E, 0xAC, 0xB4,
0x5A, 0x0F, 0x73, 0x13, 0xAC, 0x9E, 0xDA, 0xB6, 0xB8, 0x96, 0x5B, 0x60, 0x88, 0xE1, 0x81, 0x3F,
0x07, 0x86, 0x37, 0x2D, 0x79, 0x14, 0x52, 0xEA, 0x73, 0xDF, 0x3D, 0x09, 0xC8, 0x25, 0x48, 0xD8,
0x75, 0x60, 0x9A, 0x08, 0x27, 0x4A, 0x2C, 0xB9, 0xA8, 0x8B, 0x8A, 0x73, 0x62, 0x37, 0x16, 0x02,
0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B,
0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA,
0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x72, 0x39, 0x25, 0x67, 0x26, 0x6D, 0x71,
0x36, 0x77, 0x3C, 0x20, 0x62, 0x23, 0x68, 0x74, 0xC3, 0x82, 0xC9, 0x15, 0x57, 0x16, 0x5D, 0x81,
];
const VARIANTS: [u8; 32] = [
0xB7, 0x74, 0x85, 0xD0, 0xCC, 0xDB, 0xCA, 0x73, 0x03, 0xFE, 0x31, 0x03, 0x52, 0xE0, 0xB7, 0x42,
0x63, 0x16, 0xF2, 0x2A, 0x79, 0x52, 0xFF, 0x1B, 0x7A, 0x11, 0xCA, 0x1A, 0x9B, 0x40, 0xAD, 0x01,
];
const SECRET: [u8; 5] = [0x55, 0xD6, 0xC4, 0xC5, 0x28];
const PERM_CHALLENGE: [[usize; 10]; 3] = [
[1, 3, 0, 7, 5, 2, 9, 6, 4, 8],
[6, 1, 9, 3, 8, 5, 7, 4, 0, 2],
[4, 0, 3, 5, 7, 2, 8, 6, 1, 9],
];
const PERM_VARIANT: [[u8; 32]; 2] = [
[
0x0A, 0x08, 0x0E, 0x0C, 0x0B, 0x09, 0x0F, 0x0D, 0x1A, 0x18, 0x1E, 0x1C, 0x1B, 0x19, 0x1F,
0x1D, 0x02, 0x00, 0x06, 0x04, 0x03, 0x01, 0x07, 0x05, 0x12, 0x10, 0x16, 0x14, 0x13, 0x11,
0x17, 0x15,
],
[
0x12, 0x1A, 0x16, 0x1E, 0x02, 0x0A, 0x06, 0x0E, 0x10, 0x18, 0x14, 0x1C, 0x00, 0x08, 0x04,
0x0C, 0x13, 0x1B, 0x17, 0x1F, 0x03, 0x0B, 0x07, 0x0F, 0x11, 0x19, 0x15, 0x1D, 0x01, 0x09,
0x05, 0x0D,
],
];
// ── SCSI constants ────────────────────────────────────────────────────────
const SCSI_READ_DVD_STRUCTURE: u8 = 0xAD;
// ── Public API ────────────────────────────────────────────────────────────
/// Perform CSS bus authentication only.
pub fn authenticate(drive: &mut Drive) -> Result<()> {
let (_, _) = bus_auth(drive)?;
Ok(())
}
/// Full CSS key extraction: bus auth → disc key → title key.
pub fn authenticate_and_read_title_key(drive: &mut Drive, lba: u32) -> Result<[u8; 5]> {
// Session 1: bus auth → disc key (AGID consumed by READ_DVD_STRUCTURE)
let (agid, bus_key) = bus_auth(drive)?;
let disc_key = read_disc_key(drive, agid, &bus_key)?;
// Session 2: fresh bus auth → title key (needs separate AGID)
let (agid2, bus_key2) = bus_auth(drive)?;
let encrypted_title = read_raw_title_key(drive, agid2, lba)?;
// Decrypt title key: XOR with bus key, then decrypt with disc key
let mut title_key = [0u8; 5];
for i in 0..5 {
title_key[i] = encrypted_title[i] ^ bus_key2[i];
}
if title_key == [0u8; 5] {
return Ok(title_key);
}
let title_key = super::lfsr::decrypt_key(0xFF, &disc_key, &title_key);
Ok(title_key)
}
// ── Step 1: Bus Authentication ────────────────────────────────────────────
fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
let scsi = drive.scsi_mut();
// Invalidate all AGIDs via REPORT KEY format 0x3F
for agid in 0..4u8 {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
// alloc_len = 0 (no data transfer)
cdb[10] = (agid << 6) | 0x3F;
let mut buf = [0u8; 8];
let _ = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
}
// Allocate AGID
let mut buf = [0u8; 8];
scsi.execute(
&report_key_cdb(0, 0x00, 8),
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let agid = (buf[7] >> 6) & 0x03;
// Host sends challenge
let host_challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let mut hc_buf = [0u8; 16];
hc_buf[0] = 0x00;
hc_buf[1] = 0x0E;
for i in 0..10 {
hc_buf[4 + i] = host_challenge[9 - i];
}
scsi.execute(
&send_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::ToDevice,
&mut hc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
// Get Key1 from drive
let mut dk_buf = [0u8; 12];
scsi.execute(
&report_key_cdb(agid, 0x02, 12),
crate::scsi::DataDirection::FromDevice,
&mut dk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let mut key1 = [0u8; 5];
for i in 0..5 {
key1[i] = dk_buf[4 + (4 - i)];
}
// Brute-force variant (0-31)
let mut variant: Option<u8> = None;
for v in 0..32u8 {
if crypt_key(0, v, &host_challenge) == key1 {
variant = Some(v);
break;
}
}
let variant = variant.ok_or(Error::CssAuthFailed)?;
// Get drive challenge
let mut dc_buf = [0u8; 16];
scsi.execute(
&report_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::FromDevice,
&mut dc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let mut drive_challenge = [0u8; 10];
for i in 0..10 {
drive_challenge[i] = dc_buf[4 + (9 - i)];
}
// Compute Key2 and send it
let key2 = crypt_key(1, variant, &drive_challenge);
let mut hk_buf = [0u8; 12];
hk_buf[0] = 0x00;
hk_buf[1] = 0x0A;
for i in 0..5 {
hk_buf[4 + i] = key2[4 - i];
}
scsi.execute(
&send_key_cdb(agid, 0x03, 12),
crate::scsi::DataDirection::ToDevice,
&mut hk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
// Bus key = CryptKey(2, variant, key1 || key2)
let mut combined = [0u8; 10];
combined[..5].copy_from_slice(&key1);
combined[5..].copy_from_slice(&key2);
let bus_key = crypt_key(2, variant, &combined);
Ok((agid, bus_key))
}
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
fn read_disc_key(drive: &mut Drive, agid: u8, bus_key: &[u8; 5]) -> Result<[u8; 5]> {
let scsi = drive.scsi_mut();
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
let alloc_len: u16 = 2048 + 4;
let mut cdb = [0u8; 12];
cdb[0] = SCSI_READ_DVD_STRUCTURE;
// bytes 2-5: address = 0
cdb[6] = 0; // layer
cdb[7] = 0x02; // format = disc key
cdb[8] = (alloc_len >> 8) as u8;
cdb[9] = alloc_len as u8;
cdb[10] = agid << 6;
let mut buf = vec![0u8; alloc_len as usize];
let dvd_result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
dvd_result.map_err(|_| Error::CssAuthFailed)?;
// Disc key block starts at offset 4 (skip 4-byte header)
let disc_key_block = &mut buf[4..4 + 2048];
// XOR with reversed bus key (per libdvdcss)
for (i, byte) in disc_key_block.iter_mut().enumerate() {
*byte ^= bus_key[4 - (i % 5)];
}
// Try each player key against each of 408 disc key entries.
// Each entry in the block is the disc key encrypted with a specific player key.
// We try all known player keys and verify by checking that two different
// entries produce the same disc key.
let mut candidates: Vec<([u8; 5], usize, usize)> = Vec::new(); // (disc_key, pk_idx, pos)
for (pk_idx, player_key) in PLAYER_KEYS.iter().enumerate() {
for pos in 0..408 {
let offset = pos * 5;
if offset + 5 > disc_key_block.len() {
break;
}
let mut enc = [0u8; 5];
enc.copy_from_slice(&disc_key_block[offset..offset + 5]);
let candidate = super::lfsr::decrypt_key(0x00, player_key, &enc);
// Check if any previous candidate matches (same disc key from different entry/pk)
for (prev, _, _) in &candidates {
if *prev == candidate {
return Ok(candidate);
}
}
candidates.push((candidate, pk_idx, pos));
}
}
Err(Error::CssAuthFailed)
}
// ── Step 3: Title Key ─────────────────────────────────────────────────────
/// Read the raw (bus-encrypted) title key bytes from the drive.
fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<[u8; 5]> {
let scsi = drive.scsi_mut();
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[2] = (lba >> 24) as u8;
cdb[3] = (lba >> 16) as u8;
cdb[4] = (lba >> 8) as u8;
cdb[5] = lba as u8;
cdb[8] = 0x00;
cdb[9] = 0x0C;
cdb[10] = (agid << 6) | 0x04;
let mut buf = [0u8; 12];
let result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
result.map_err(|_| Error::CssAuthFailed)?;
let mut key = [0u8; 5];
for i in 0..5 {
key[i] = buf[5 + (4 - i)];
}
Ok(key)
}
#[allow(dead_code)]
fn read_title_key(
drive: &mut Drive,
agid: u8,
lba: u32,
bus_key: &[u8; 5],
disc_key: &[u8; 5],
) -> Result<[u8; 5]> {
let scsi = drive.scsi_mut();
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[2] = (lba >> 24) as u8;
cdb[3] = (lba >> 16) as u8;
cdb[4] = (lba >> 8) as u8;
cdb[5] = lba as u8;
cdb[8] = 0x00;
cdb[9] = 0x0C;
cdb[10] = (agid << 6) | 0x04;
let mut buf = [0u8; 12];
let tk_result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
tk_result.map_err(|_| Error::CssAuthFailed)?;
// Title key at bytes 5..10, byte-reversed
let mut title_key = [0u8; 5];
for i in 0..5 {
title_key[i] = buf[5 + (4 - i)];
}
// XOR with reversed bus key (same pattern as disc key block)
for i in 0..5 {
title_key[i] ^= bus_key[4 - i];
}
// Check for null key (title not encrypted)
if title_key == [0u8; 5] {
return Ok(title_key);
}
// Decrypt with disc key (invert=0xFF for title keys)
let title_key = super::lfsr::decrypt_key(0xFF, disc_key, &title_key);
Ok(title_key)
}
// ── CSSCryptKey ───────────────────────────────────────────────────────────
/// Exposed for testing only.
pub fn test_crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
crypt_key(key_type, variant, challenge)
}
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
let perm = &PERM_CHALLENGE[key_type];
let mut scratch = [0u8; 10];
for i in 0..10 {
scratch[i] = challenge[perm[i]];
}
let css_variant = match key_type {
0 => variant as usize,
1 => PERM_VARIANT[0][variant as usize] as usize,
_ => PERM_VARIANT[1][variant as usize] as usize,
};
let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant];
let mut tmp1 = [0u8; 5];
for i in 0..5 {
tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i];
}
let mut lfsr0: u32 = ((tmp1[0] as u32) << 17)
| ((tmp1[1] as u32) << 9)
| (((tmp1[2] as u32) & !7) << 1)
| 8
| (tmp1[2] as u32 & 7);
let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32);
let mut bits = [0u8; 30];
let mut carry: u32 = 0;
for idx in (0..30).rev() {
let mut val: u8 = 0;
for bit in 0..8u8 {
let lfsr0_out = ((lfsr0 >> 24) ^ (lfsr0 >> 21) ^ (lfsr0 >> 20) ^ (lfsr0 >> 12)) & 1;
lfsr0 = ((lfsr0 << 1) | lfsr0_out) & 0x1FFFFFF;
let lfsr1_out = ((lfsr1 >> 16) ^ (lfsr1 >> 2)) & 1;
lfsr1 = ((lfsr1 << 1) | lfsr1_out) & 0x1FFFF;
let combined = ((!lfsr1_out) & 1) + carry + ((!lfsr0_out) & 1);
carry = (combined >> 1) & 1;
val |= ((combined & 1) as u8) << bit;
}
bits[idx] = val;
}
let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]];
let mut tmp2 = [0u8; 5];
// Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[25 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = scratch[i]; // original challenge, NOT modified tmp1
}
tmp1[4] ^= tmp1[0];
}
// Round 2
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[20 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp2[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp1[i];
}
tmp2[4] ^= tmp2[0];
}
// Round 3 (uses CRYPT_TAB0)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[15 + i] ^ tmp2[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
tmp1[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
term = tmp2[i];
}
tmp1[4] ^= tmp1[0];
}
// Round 4 (uses CRYPT_TAB0)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[10 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
tmp2[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
term = tmp1[i];
}
tmp2[4] ^= tmp2[0];
}
// Round 5
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[5 + i] ^ tmp2[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp2[i];
}
tmp1[4] ^= tmp1[0];
}
// Round 6
let mut key = [0u8; 5];
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
key[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp1[i];
}
}
key
}
// ── SCSI CDB builders ────────────────────────────────────────────────────
fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[8] = (alloc_len >> 8) as u8;
cdb[9] = alloc_len as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_SEND_KEY;
cdb[8] = (param_len >> 8) as u8;
cdb[9] = param_len as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crypt_key_is_deterministic() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
let r1 = crypt_key(0, v, &challenge);
let r2 = crypt_key(0, v, &challenge);
assert_eq!(r1, r2);
}
}
#[test]
fn crypt_key_varies_by_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
}
#[test]
fn crypt_key_nonzero() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
}
}
#[test]
fn player_keys_count() {
assert_eq!(PLAYER_KEYS.len(), 31);
}
}
+35 -24
View File
@@ -197,37 +197,48 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
//
// We try multiple stream IDs and use zeros for unknown bytes (most common).
let stream_ids: &[u8] = &[
0xE0, // video
0xBD, // private stream 1 (AC3/DTS)
0xC0, // MPEG audio
0xBE, // padding
];
// Try many PES header patterns at byte 0x80.
// Structure: 00 00 01 [stream_id] [len_hi] [len_lo] [flags1] [flags2] [hdr_len] [data]
let mut patterns: Vec<[u8; 10]> = Vec::with_capacity(128);
for &sid in stream_ids {
// Build candidate plaintext (10 bytes)
// Bytes 0-2: PES start code 00 00 01
// Byte 3: stream ID
// Bytes 4-9: we try with zeros first (common for padding streams)
// and with typical PES header bytes
let patterns: &[[u8; 10]] = &[
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21],
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00],
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
];
// Padding stream (0xBE): payload is 0xFF bytes, various lengths
for len_hi in 0u8..8 {
for len_lo_top in [0x00u8, 0x80, 0xFF] {
patterns.push([
0x00, 0x00, 0x01, 0xBE, len_hi, len_lo_top, 0xFF, 0xFF, 0xFF, 0xFF,
]);
}
}
for pattern in patterns {
if let Some(key) = recover_title_key(sector, pattern) {
// Verify: the key should produce valid MPEG-2 when used to descramble
let mut test = sector.to_vec();
super::lfsr::descramble_sector(&key, &mut test);
if test[0x80] == 0x00 && test[0x81] == 0x00 && test[0x82] == 0x01 {
return Some(key);
// Video (0xE0) and audio (0xBD, 0xC0) with typical PES headers
for &sid in &[0xE0u8, 0xBD, 0xC0] {
for &flags1 in &[0x80u8, 0x81, 0x84, 0x85, 0x8C, 0x8D] {
for &flags2 in &[0x00u8, 0x05, 0x80, 0xC0] {
let hdr_len = if flags2 & 0x80 != 0 { 0x05u8 } else { 0x00 };
let pts0 = if flags2 & 0x80 != 0 { 0x21u8 } else { 0x00 };
// Try with several PES lengths
for &len_hi in &[0x00u8, 0x07] {
patterns.push([
0x00, 0x00, 0x01, sid, len_hi, 0x00, flags1, flags2, hdr_len, pts0,
]);
}
}
}
}
// Navigation pack system header (0xBB)
patterns.push([0x00, 0x00, 0x01, 0xBB, 0x00, 0x12, 0x80, 0xC4, 0xE1, 0x04]);
for pattern in &patterns {
if let Some(key) = recover_title_key(sector, pattern) {
let mut test = sector.to_vec();
super::lfsr::descramble_sector(&key, &mut test);
if test[0x80] == 0x00 && test[0x81] == 0x00 && test[0x82] == 0x01 {
return Some(key);
}
}
}
None
}
+19 -53
View File
@@ -10,7 +10,7 @@
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
//! Tables: CSS specification constants.
use super::tables::{TAB1, TAB2, TAB3, TAB4};
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
/// Descramble a CSS-encrypted DVD sector in place.
///
@@ -57,19 +57,16 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
let mut combined: u32 = 0;
// Generate 1920 keystream bytes (for sector bytes 128..2048)
// Per libdvdcss css_unscramble: TAB1 permutation on ciphertext, no invert on LFSR0
for byte in sector.iter_mut().take(2048).skip(128) {
// Clock LFSR1
let o_lfsr1 = TAB2[lfsr1_hi as usize] ^ TAB3[lfsr1_lo as usize];
lfsr1_hi = lfsr1_lo >> 1;
lfsr1_lo = ((lfsr1_lo & 1) << 8) ^ o_lfsr1 as u32;
let o_lfsr1_perm = TAB4[o_lfsr1 as usize];
// Clock LFSR0
let o_lfsr0 = (((((((lfsr0 >> 8) ^ lfsr0) >> 1) ^ lfsr0) >> 3) ^ lfsr0) >> 7) as u8;
lfsr0 = (lfsr0 >> 8) | ((o_lfsr0 as u32) << 24);
// Combine with addition and carry
combined += (o_lfsr0 ^ 0xFF) as u32 + o_lfsr1_perm as u32;
combined += TAB5[o_lfsr1 as usize] as u32 + TAB4[o_lfsr0 as usize] as u32;
*byte ^= (combined & 0xFF) as u8;
combined >>= 8;
}
@@ -106,12 +103,12 @@ pub(crate) fn decrypt_key(invert: u8, p_key: &[u8; 5], p_crypted: &[u8]) -> [u8;
let o_lfsr1 = TAB2[lfsr1_hi as usize] ^ TAB3[lfsr1_lo as usize];
lfsr1_hi = lfsr1_lo >> 1;
lfsr1_lo = ((lfsr1_lo & 1) << 8) ^ o_lfsr1 as u32;
let o_lfsr1_perm = TAB4[o_lfsr1 as usize];
let o_lfsr0 = (((((((lfsr0 >> 8) ^ lfsr0) >> 1) ^ lfsr0) >> 3) ^ lfsr0) >> 7) as u8;
lfsr0 = (lfsr0 >> 8) | ((o_lfsr0 as u32) << 24);
combined += (o_lfsr0 ^ invert) as u32 + o_lfsr1_perm as u32;
// TAB5 for LFSR1 output, TAB4 for LFSR0^invert (per libdvdcss css_DecryptKey)
combined += TAB5[o_lfsr1 as usize] as u32 + TAB4[(o_lfsr0 ^ invert) as usize] as u32;
*byte = (combined & 0xFF) as u8;
combined >>= 8;
}
@@ -237,58 +234,27 @@ mod tests {
/// twice with the same key and restored scramble flag should roundtrip,
/// since XOR is its own inverse.
#[test]
fn css_descramble_produces_valid_mpeg2() {
fn css_descramble_modifies_encrypted_region() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
// Build a sector with MPEG-2 pack header and PES header
let mut sector = vec![0x00u8; 2048];
// Pack header at byte 0
sector[0] = 0x00;
sector[1] = 0x00;
sector[2] = 0x01;
sector[3] = 0xBA;
// Scramble flag at byte 0x14
sector[0x14] = 0x30;
// Sector seed at bytes 0x54-0x58
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30; // scramble flag
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
// PES header at byte 128
sector[0x80] = 0x00;
sector[0x81] = 0x00;
sector[0x82] = 0x01;
sector[0x83] = 0xE0;
// Fill some content in the encrypted region
for (i, byte) in sector.iter_mut().enumerate().take(2048).skip(0x84) {
*byte = (i & 0xFF) as u8;
}
let original = sector.clone();
// First descramble: "encrypts" by XORing keystream
descramble_sector(&title_key, &mut sector);
// Flag should be cleared
assert_eq!(
sector[0x14] & 0x30,
0x00,
"scramble flag not cleared after first descramble"
);
// Encrypted region should differ
assert_ne!(
&sector[0x80..0x84],
&original[0x80..0x84],
"encrypted region unchanged after descramble"
);
// Restore the scramble flag and sector seed for second pass
sector[0x14] = 0x30;
// Second descramble: XOR again = roundtrip
descramble_sector(&title_key, &mut sector);
// Now the encrypted region should match original
assert_eq!(
&sector[0x80..2048],
&original[0x80..2048],
"double descramble did not roundtrip"
);
// Flag cleared
assert_eq!(sector[0x14] & 0x30, 0x00);
// Header (0..128) unchanged except flag byte
for i in 0..128 {
if i == 0x14 {
continue;
}
assert_eq!(sector[i], original[i], "header byte {} changed", i);
}
// Encrypted region modified
assert_ne!(&sector[128..256], &original[128..256]);
}
/// Test 4: css_tab1_relationship
+28 -18
View File
@@ -10,6 +10,7 @@
//! css::descramble_sector(&key, &mut sector);
//! ```
pub mod auth;
pub mod crack;
pub mod lfsr;
pub(crate) mod tables;
@@ -27,29 +28,38 @@ pub struct CssState {
/// Crack the CSS title key by reading encrypted sectors and applying
/// a known-plaintext attack on MPEG-2 headers.
///
/// Reads a few sectors from the first extent, finds one with the
/// scramble flag set, and cracks the key.
/// Crack the CSS title key by scanning scrambled sectors across extents.
///
/// The Stevenson attack needs a sector where a PES header starts at byte
/// 0x80 (start of the encrypted region). This only happens when a new PES
/// packet begins at exactly sector offset 128. We scan up to 50000
/// scrambled sectors sequentially across all extents.
pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option<CssState> {
if extents.is_empty() {
return None;
}
let mut tried = 0u32;
let max_tries = 50_000;
let ext = &extents[0];
let mut sectors = Vec::new();
// Read first 10 sectors from the main extent
let count = ext.sector_count.min(10);
for i in 0..count {
let mut buf = vec![0u8; 2048];
if reader.read_sectors(ext.start_lba + i, 1, &mut buf).is_ok() {
sectors.push(buf);
for ext in extents {
let mut i = 0;
while i < ext.sector_count && tried < max_tries {
let mut buf = vec![0u8; 2048];
if reader
.read_sectors(ext.start_lba + i, 1, &mut buf, true)
.is_ok()
&& is_scrambled(&buf)
{
if let Some(key) = crack::crack_title_key(&buf) {
return Some(CssState { title_key: key });
}
tried += 1;
}
i += 1;
}
if tried >= max_tries {
break;
}
}
// Try cracking from the collected sectors
let key = crack::crack_from_sectors(&sectors)?;
Some(CssState { title_key: key })
None
}
/// Descramble a single CSS-encrypted sector in place.
+13 -12
View File
@@ -66,17 +66,18 @@ impl Disc {
pkt_count = clip_info.source_packet_count;
total_size += pkt_count as u64 * 192;
// Get m2ts file start LBA and compute extent from packet count.
// BD-ROM m2ts files are contiguous on disc (mastering requirement).
// Get m2ts file extents from UDF allocation descriptors.
// Dual-layer discs split files across layers — UDF knows the real layout.
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
let file_lba = udf_fs.file_start_lba(reader, &m2ts_path).unwrap_or(0);
let total_bytes = pkt_count as u64 * 192;
let total_sectors = total_bytes.div_ceil(2048) as u32;
if total_sectors > 0 && file_lba > 0 {
extents.push(Extent {
start_lba: file_lba,
sector_count: total_sectors,
});
if let Ok(file_exts) = udf_fs.file_extents(reader, &m2ts_path) {
for (lba, sectors) in file_exts {
if sectors > 0 && lba > 0 {
extents.push(Extent {
start_lba: lba,
sector_count: sectors,
});
}
}
}
}
}
@@ -158,12 +159,12 @@ impl Disc {
})
.collect();
// Convert marks to chapters (filter mark_type == 1 = chapter entry)
// Convert marks to chapters (mark_type 0 or 1 = chapter entry, 2 = link)
let first_in_time = parsed.play_items.first().map(|pi| pi.in_time).unwrap_or(0);
let chapters: Vec<Chapter> = parsed
.marks
.iter()
.filter(|m| m.mark_type == 1)
.filter(|m| m.mark_type <= 1)
.enumerate()
.map(|(i, m)| {
let time_secs = (m.timestamp as f64 - first_in_time as f64) / 45000.0;
+12 -2
View File
@@ -101,6 +101,16 @@ impl Disc {
streams.extend(audio_streams.iter().cloned());
streams.extend(subtitle_streams);
let chapters: Vec<Chapter> = dvd_title
.chapter_times
.iter()
.enumerate()
.map(|(i, &t)| Chapter {
time_secs: t,
name: format!("Chapter {}", i + 1),
})
.collect();
titles.push(DiscTitle {
playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number),
playlist_id: title_number,
@@ -108,10 +118,10 @@ impl Disc {
size_bytes,
clips: Vec::new(),
streams,
chapters: Vec::new(),
chapters,
extents,
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
codec_privates: Vec::new(),
});
}
}
+128 -20
View File
@@ -874,7 +874,8 @@ impl KeySource {
/// Standard KEYDB.cfg search locations (compatible with libaacs).
const KEYDB_SEARCH_PATHS: &[&str] = &[
".config/aacs/KEYDB.cfg", // relative to $HOME
".config/aacs/KEYDB.cfg", // libaacs standard path
".config/freemkv/keydb.cfg", // freemkv download path
];
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
@@ -917,12 +918,75 @@ impl ScanOptions {
}
}
/// Quick disc identification — name, format, capacity. No title/stream parsing.
#[derive(Debug)]
pub struct DiscId {
/// UDF Volume Identifier (always present, e.g. "V_FOR_VENDETTA")
pub volume_id: String,
/// Disc title from META/DL/bdmt_eng.xml (e.g. "V for Vendetta")
pub meta_title: Option<String>,
/// Disc format (BD, UHD, DVD) — UHD vs BD requires full scan to confirm
pub format: DiscFormat,
/// Disc capacity in sectors
pub capacity_sectors: u32,
/// Whether AACS directory exists (disc is likely encrypted)
pub encrypted: bool,
/// Number of layers
pub layers: u8,
}
impl DiscId {
/// Best available name: meta_title, then formatted volume_id.
pub fn name(&self) -> &str {
self.meta_title.as_deref().unwrap_or(&self.volume_id)
}
}
impl Disc {
/// Fast disc identification — reads only UDF metadata for name and format.
/// No AACS handshake, no playlist parsing, no CLPI, no labels.
/// Typically completes in 2-3 seconds on USB drives.
pub fn identify(session: &mut Drive) -> Result<DiscId> {
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
let format = if udf_fs.find_dir("/BDMV").is_some() {
DiscFormat::BluRay // full scan distinguishes UHD vs BD
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
DiscFormat::Dvd
} else {
DiscFormat::Unknown
};
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
let layers = if capacity > 24_000_000 { 2 } else { 1 };
Ok(DiscId {
volume_id: udf_fs.volume_id,
meta_title,
format,
capacity_sectors: capacity,
encrypted,
layers,
})
}
/// Disc capacity in GB
pub fn capacity_gb(&self) -> f64 {
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
}
/// Read UDF filesystem and set up buffered reader with metadata prefetched.
/// Shared setup for both identify() and scan().
fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> {
let capacity = Self::read_capacity(session).unwrap_or(0);
let batch = detect_max_batch_sectors(session.device_path());
let mut buffered = udf::BufferedSectorReader::new(session, batch);
let udf_fs = udf::read_filesystem(&mut buffered)?;
buffered.prefetch(udf_fs.metadata_start(), udf_fs.metadata_sectors());
Ok((capacity, buffered, udf_fs))
}
/// Scan a disc -- parse filesystem, playlists, streams, and set up AACS decryption.
///
/// This is the main entry point. After scan(), the Disc is ready:
@@ -931,19 +995,61 @@ impl Disc {
/// - content can be read and decrypted transparently
///
/// Scan a disc. One pipeline, one order:
/// 1. Read capacity
/// 2. Read UDF filesystem
/// 3. Resolve AACS keys (all via UDF, no SCSI commands)
/// 4. Parse playlists + streams
/// 5. Apply labels
/// 1. Read capacity + UDF filesystem
/// 2. AACS handshake + key resolution
/// 3. Parse playlists + streams
/// 4. Apply labels
///
/// The session must be open and unlocked (Drive::open handles this).
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
// READ CAPACITY may fail in LibreDrive mode — proceed with 0 and estimate later
let capacity = Self::read_capacity(session).unwrap_or(0);
// AACS handshake (Blu-ray/UHD)
let handshake = Self::do_handshake(session, opts);
Self::scan_with(session, capacity, handshake, opts)
// Request max read speed — removes riplock on DVD
// (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED)
session.set_speed(0xFFFF);
// Read UDF filesystem with buffered sector reader
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
// Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv).
// Without this, each read_file() triggers individual SCSI commands at 500ms each.
if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) {
buffered.prefetch_ranges(&ranges);
}
let mut disc = Self::scan_with(&mut buffered, capacity, handshake, opts, udf_fs)?;
// CSS key extraction for DVDs (bus auth → disc key → title key).
// Must be a single auth session — can't call authenticate() separately.
if disc.css.is_none()
&& disc.content_format == ContentFormat::MpegPs
&& !disc.titles.is_empty()
{
let lba = disc.titles[0].extents.iter().find_map(|ext| {
let mut buf = vec![0u8; 2048];
if session
.read_sectors(ext.start_lba, 1, &mut buf, true)
.is_ok()
&& crate::css::is_scrambled(&buf)
{
return Some(ext.start_lba);
}
None
});
if let Some(lba) = lba {
if let Ok(title_key) =
crate::css::auth::authenticate_and_read_title_key(session, lba)
{
disc.css = Some(crate::css::CssState { title_key });
disc.encrypted = true;
}
}
}
Ok(disc)
}
/// Scan a disc image (ISO or any SectorReader). No SCSI, no handshake.
@@ -953,7 +1059,8 @@ impl Disc {
capacity: u32,
opts: &ScanOptions,
) -> Result<Self> {
Self::scan_with(reader, capacity, None, opts)
let udf_fs = udf::read_filesystem(reader)?;
Self::scan_with(reader, capacity, None, opts, udf_fs)
}
/// Core scan pipeline — works with any SectorReader.
@@ -962,10 +1069,8 @@ impl Disc {
capacity: u32,
handshake: Option<HandshakeResult>,
opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
// 1. UDF filesystem
let udf_fs = udf::read_filesystem(reader)?;
// 2. Resolve encryption (AACS, CSS, or none)
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
@@ -1003,6 +1108,7 @@ impl Disc {
// 4. Metadata + labels
let meta_title = Self::read_meta_title(reader, &udf_fs);
crate::labels::apply(reader, &udf_fs, &mut titles);
crate::labels::fill_defaults(&mut titles);
// 5. Derive format, layers, region
let format = Self::detect_format(&titles);
@@ -1119,7 +1225,11 @@ impl Disc {
use std::io::{Seek, SeekFrom, Write};
let total_bytes = self.capacity_sectors as u64 * 2048;
let keys = if decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None };
let keys = if decrypt {
self.decrypt_keys()
} else {
crate::decrypt::DecryptKeys::None
};
// Resume: check existing file
let (start_lba, file) = if resume {
@@ -1138,14 +1248,13 @@ impl Disc {
(safe_sectors, f)
}
_ => {
let f = std::fs::File::create(path)
.map_err(|e| Error::IoError { source: e })?;
let f =
std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
(0u32, f)
}
}
} else {
let f = std::fs::File::create(path)
.map_err(|e| Error::IoError { source: e })?;
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
(0u32, f)
};
@@ -1160,9 +1269,8 @@ impl Disc {
let count = remaining.min(batch as u32) as u16;
let bytes = count as usize * 2048;
reader
.read_sectors(lba, count, &mut buf[..bytes])
.read_sectors(lba, count, &mut buf[..bytes], true)
.map_err(|e| Error::IoError {
source: std::io::Error::other(e.to_string()),
})?;
-5
View File
@@ -10,11 +10,6 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
if !std::path::Path::new(&path).exists() {
continue;
}
// Skip stale device nodes — sysfs entry must exist
let sysfs = format!("/sys/class/scsi_generic/sg{i}/device/model");
if !std::path::Path::new(&sysfs).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()) {
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
+157 -16
View File
@@ -16,6 +16,7 @@ mod macos;
mod windows;
use crate::error::{Error, Result};
use crate::event::{Event, EventKind};
use crate::identity::DriveId;
use crate::platform::mt1959::Mt1959;
use crate::platform::PlatformDriver;
@@ -23,6 +24,8 @@ use crate::profile::{self, DriveProfile};
use crate::scsi::ScsiTransport;
use crate::sector::SectorReader;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// Physical state of the drive tray and disc.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -59,8 +62,11 @@ pub struct Drive {
pub drive_id: DriveId,
device_path: String,
/// Bytes remaining in the min-speed recovery window.
/// After a read error, we stay at min speed for RECOVERY_WINDOW bytes.
recovery_bytes_remaining: u64,
/// Halt flag — when set, Drive::read() bails at the next check point.
halt: Arc<AtomicBool>,
/// Event handler — fires during read recovery.
event_fn: Option<Box<dyn Fn(Event) + Send>>,
}
impl Drive {
@@ -87,9 +93,41 @@ impl Drive {
drive_id,
device_path: device.to_string_lossy().to_string(),
recovery_bytes_remaining: 0,
halt: Arc::new(AtomicBool::new(false)),
event_fn: None,
})
}
/// Get a clone of the halt flag. Set to true to interrupt Drive::read().
pub fn halt_flag(&self) -> Arc<AtomicBool> {
self.halt.clone()
}
/// Halt the drive — Drive::read() will bail at the next check point.
pub fn halt(&self) {
self.halt.store(true, Ordering::Relaxed);
}
/// Clear the halt flag for the next operation.
pub fn clear_halt(&self) {
self.halt.store(false, Ordering::Relaxed);
}
/// Set an event handler for read recovery events.
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
self.event_fn = Some(Box::new(f));
}
fn emit(&self, kind: EventKind) {
if let Some(ref f) = self.event_fn {
f(Event { kind });
}
}
fn is_halted(&self) -> bool {
self.halt.load(Ordering::Relaxed)
}
/// Close the drive cleanly. Unlocks tray, flushes SCSI state, closes fd.
/// Also runs automatically on Drop as a safety net.
pub fn close(self) {
@@ -111,6 +149,11 @@ impl Drive {
self.profile.is_some()
}
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
self.scsi.as_mut()
}
pub fn wait_ready(&mut self) -> Result<()> {
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut tried_reset = false;
@@ -150,7 +193,18 @@ impl Drive {
/// Uses GET EVENT STATUS NOTIFICATION which works regardless of firmware state.
pub fn drive_status(&mut self) -> DriveStatus {
// GET EVENT STATUS NOTIFICATION: polled, media event class (0x10)
let cdb = [SCSI_GET_EVENT_STATUS, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x08, 0x00];
let cdb = [
SCSI_GET_EVENT_STATUS,
0x01,
0x00,
0x00,
0x10,
0x00,
0x00,
0x00,
0x08,
0x00,
];
let mut buf = [0u8; 8];
match self.scsi.as_mut().execute(
&cdb,
@@ -343,7 +397,18 @@ impl Drive {
/// Read REPORT KEY RPC state (region playback control).
pub fn report_key_rpc_state(&mut self) -> Option<Vec<u8>> {
let cdb = [
SCSI_REPORT_KEY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00,
SCSI_REPORT_KEY,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x08,
0x08,
0x00,
];
let mut buf = vec![0u8; 8];
let r = self
@@ -365,7 +430,18 @@ impl Drive {
/// Read MODE SENSE page data.
pub fn mode_sense_page(&mut self, page: u8) -> Option<Vec<u8>> {
let cdb = [SCSI_MODE_SENSE, 0x00, page, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00];
let cdb = [
SCSI_MODE_SENSE,
0x00,
page,
0x00,
0x00,
0x00,
0x00,
0x00,
0xFC,
0x00,
];
let mut buf = vec![0u8; 252];
let r = self
.scsi
@@ -420,8 +496,14 @@ impl Drive {
///
/// Returns Err only after all attempts exhausted — user should clean
/// the disc and resume.
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let timeout_ms = if self.recovery_bytes_remaining > 0 { 30_000 } else { 10_000 };
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
let timeout_ms = if !recovery {
5_000
} else if self.recovery_bytes_remaining > 0 {
30_000
} else {
10_000
};
let cdb = [
crate::scsi::SCSI_READ_10,
0x00,
@@ -437,33 +519,60 @@ impl Drive {
// Normal read
if let Ok(result) = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, timeout_ms,
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
timeout_ms,
) {
if self.recovery_bytes_remaining > 0 {
let bytes_read = count as u64 * 2048;
self.recovery_bytes_remaining =
self.recovery_bytes_remaining.saturating_sub(bytes_read);
if self.recovery_bytes_remaining == 0 {
self.emit(EventKind::SpeedChange { speed_kbs: 0xFFFF });
self.set_speed(0xFFFF);
}
}
return Ok(result.bytes_transferred);
}
// Phase 1: gentle — sleep 30s, retry. 5 times.
// Read failed
if !recovery {
return Err(Error::DiscRead { sector: lba as u64 });
}
// Enter recovery
self.emit(EventKind::ReadError {
sector: lba as u64,
error: Error::DiscRead { sector: lba as u64 },
});
self.emit(EventKind::SpeedChange { speed_kbs: 0 });
self.set_speed(0);
for _ in 0..5 {
// Phase 1: gentle — sleep 30s, retry. 5 times.
for attempt in 1..=5u32 {
if self.is_halted() {
return Err(Error::Halted);
}
self.emit(EventKind::Retry { attempt });
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(result) = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000,
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
30_000,
) {
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
}
}
if self.is_halted() {
return Err(Error::Halted);
}
// Phase 2: fresh start — close, reset, open, init.
let device = std::path::PathBuf::from(&self.device_path);
std::thread::sleep(std::time::Duration::from_secs(5));
@@ -474,13 +583,25 @@ impl Drive {
let _ = self.wait_ready();
self.set_speed(0);
if self.is_halted() {
return Err(Error::Halted);
}
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
for _ in 0..5 {
for attempt in 6..=10u32 {
if self.is_halted() {
return Err(Error::Halted);
}
self.emit(EventKind::Retry { attempt });
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(result) = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000,
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
30_000,
) {
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
}
@@ -524,7 +645,14 @@ impl Drive {
/// Lock the tray so the disc cannot be ejected during a rip.
pub fn lock_tray(&mut self) {
let prevent = [SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, 0x00, 0x00, 0x00, 0x01, 0x00];
let prevent = [
SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL,
0x00,
0x00,
0x00,
0x01,
0x00,
];
let mut buf = [0u8; 0];
let _ =
self.scsi
@@ -534,7 +662,14 @@ impl Drive {
/// Unlock the tray so the user can manually eject the disc.
pub fn unlock_tray(&mut self) {
let allow = [SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, 0x00, 0x00, 0x00, 0x00, 0x00];
let allow = [
SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL,
0x00,
0x00,
0x00,
0x00,
0x00,
];
let mut buf = [0u8; 0];
let _ =
self.scsi
@@ -575,8 +710,14 @@ impl Drop for Drive {
}
impl SectorReader for Drive {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.read(lba, count, buf)
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
self.read(lba, count, buf, recovery)
}
}
+96 -26
View File
@@ -41,6 +41,7 @@ pub const E_IO_ERROR: u16 = 5000;
// Disc format (6xxx)
pub const E_DISC_READ: u16 = 6000;
pub const E_HALTED: u16 = 6010;
pub const E_MPLS_PARSE: u16 = 6001;
pub const E_CLPI_PARSE: u16 = 6002;
pub const E_UDF_NOT_FOUND: u16 = 6003;
@@ -63,6 +64,7 @@ pub const E_AACS_VID_READ: u16 = 7009;
pub const E_AACS_VID_MAC: u16 = 7010;
pub const E_AACS_DATA_KEY: u16 = 7011;
pub const E_DECRYPT_FAILED: u16 = 7013;
pub const E_CSS_AUTH_FAILED: u16 = 7014;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
@@ -89,10 +91,18 @@ pub const E_NO_METADATA: u16 = 9008;
#[derive(Debug)]
pub enum Error {
// Device (1xxx)
DeviceNotFound { path: String },
DevicePermission { path: String },
DeviceNotReady { path: String },
DeviceResetFailed { path: String },
DeviceNotFound {
path: String,
},
DevicePermission {
path: String,
},
DeviceNotReady {
path: String,
},
DeviceResetFailed {
path: String,
},
// Profile (2xxx)
UnsupportedDrive {
@@ -104,7 +114,10 @@ pub enum Error {
// Unlock (3xxx)
UnlockFailed,
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
SignatureMismatch {
expected: [u8; 4],
got: [u8; 4],
},
// SCSI (4xxx)
ScsiError {
@@ -114,14 +127,25 @@ pub enum Error {
},
// I/O (5xxx)
IoError { source: std::io::Error },
IoError {
source: std::io::Error,
},
// Disc format (6xxx)
DiscRead { sector: u64 },
DiscRead {
sector: u64,
},
/// Drive was halted by caller.
Halted,
MplsParse,
ClpiParse,
UdfNotFound { path: String },
DiscTitleRange { index: usize, count: usize },
UdfNotFound {
path: String,
},
DiscTitleRange {
index: usize,
count: usize,
},
IfoParse,
MkvInvalid,
NoStreams,
@@ -140,24 +164,43 @@ pub enum Error {
AacsVidMac,
AacsDataKey,
DecryptFailed,
CssAuthFailed,
// Keydb (8xxx)
KeydbConnect { host: String },
KeydbHttp { status: u16 },
KeydbConnect {
host: String,
},
KeydbHttp {
status: u16,
},
KeydbInvalid,
KeydbWrite { path: String },
KeydbWrite {
path: String,
},
KeydbParse,
KeydbLoad { path: String },
KeydbLoad {
path: String,
},
// Stream/mux (9xxx)
StreamReadOnly,
StreamWriteOnly,
StreamUrlInvalid { url: String },
StreamUrlMissingPath { scheme: String },
StreamUrlMissingPort { addr: String },
PesFrameTooLarge { size: usize },
StreamUrlInvalid {
url: String,
},
StreamUrlMissingPath {
scheme: String,
},
StreamUrlMissingPort {
addr: String,
},
PesFrameTooLarge {
size: usize,
},
PesInvalidMagic,
IsoTooLarge { path: String },
IsoTooLarge {
path: String,
},
NoMetadata,
}
@@ -175,6 +218,7 @@ impl Error {
Error::ScsiError { .. } => E_SCSI_ERROR,
Error::IoError { .. } => E_IO_ERROR,
Error::DiscRead { .. } => E_DISC_READ,
Error::Halted => E_HALTED,
Error::MplsParse => E_MPLS_PARSE,
Error::ClpiParse => E_CLPI_PARSE,
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
@@ -195,6 +239,7 @@ impl Error {
Error::AacsVidMac => E_AACS_VID_MAC,
Error::AacsDataKey => E_AACS_DATA_KEY,
Error::DecryptFailed => E_DECRYPT_FAILED,
Error::CssAuthFailed => E_CSS_AUTH_FAILED,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID,
@@ -227,22 +272,47 @@ impl std::fmt::Display for Error {
product_id,
product_revision,
} => write!(
f, "E{}: {} {} {}",
self.code(), vendor_id.trim(), product_id.trim(), product_revision.trim()
f,
"E{}: {} {} {}",
self.code(),
vendor_id.trim(),
product_id.trim(),
product_revision.trim()
),
Error::SignatureMismatch { expected, got } => write!(
f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
f,
"E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
self.code(),
expected[0], expected[1], expected[2], expected[3],
got[0], got[1], got[2], got[3]
expected[0],
expected[1],
expected[2],
expected[3],
got[0],
got[1],
got[2],
got[3]
),
Error::ScsiError { opcode, status, sense_key } => {
write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key)
Error::ScsiError {
opcode,
status,
sense_key,
} => {
write!(
f,
"E{}: 0x{:02x}/0x{:02x}/0x{:02x}",
self.code(),
opcode,
status,
sense_key
)
}
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
Error::Halted => write!(f, "E{}", self.code()),
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
Error::DiscTitleRange { index, count } => write!(f, "E{}: {}/{}", self.code(), index, count),
Error::DiscTitleRange { index, count } => {
write!(f, "E{}: {}/{}", self.code(), index, count)
}
Error::KeydbConnect { host } => write!(f, "E{}: {}", self.code(), host),
Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
+25 -2
View File
@@ -26,7 +26,6 @@ pub struct Event {
#[derive(Debug)]
pub enum EventKind {
// ── Init sequence events ────────────────────────────────────────
/// Drive opened successfully.
DriveOpened { device: String },
@@ -43,7 +42,6 @@ pub enum EventKind {
ScanComplete { titles: usize },
// ── Read events ─────────────────────────────────────────────────
/// Bytes successfully read and written to output.
BytesRead {
/// Bytes written so far.
@@ -82,6 +80,22 @@ pub enum EventKind {
sector_count: u64,
},
/// Sector recovered after a retry (Drive::read multi-phase recovery).
SectorRecovered { sector: u64 },
/// Sector unreadable, zero-filled (skip mode).
SectorSkipped { sector: u64 },
/// Adaptive batch sizer changed the read size.
///
/// Fires on shrink (read failed at larger size) and on probe-up
/// (enough clean reads to try larger again). Consumers use this to
/// display a "recovering" state distinct from "ripping normally".
BatchSizeChanged {
new_size: u16,
reason: BatchSizeReason,
},
/// Operation complete.
Complete {
/// Total bytes written.
@@ -91,5 +105,14 @@ pub enum EventKind {
},
}
/// Why the adaptive batch sizer changed size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchSizeReason {
/// Read failed; sizer halved the batch.
Shrunk,
/// Clean-read streak threshold hit; sizer doubled toward preferred.
Probed,
}
/// A no-op event handler. Ignores all events.
pub fn ignore(_event: Event) {}
+61 -9
View File
@@ -47,6 +47,8 @@ pub struct DvdTitle {
pub duration_secs: f64,
/// Cell sector ranges
pub cells: Vec<DvdCell>,
/// Chapter start times in seconds (derived from program map + cell times)
pub chapter_times: Vec<f64>,
/// Subtitle palette from PGC: 16 entries of [padding, Y, Cb, Cr].
pub palette: Option<Vec<[u8; 4]>>,
}
@@ -541,6 +543,39 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
duration_secs
};
// Extract chapter times from program map + cell durations
// PGC program map offset at 0xE6, maps program_number → first cell_number
let chapter_times = {
let pgm_map_offset = be_u16(data, pgc_offset + 0xE6).unwrap_or(0) as usize;
let nr_of_programs = byte_at(data, pgc_offset + 0x02).unwrap_or(0) as usize;
let mut times = Vec::new();
if pgm_map_offset > 0 && nr_of_programs > 0 && cell_playback_offset > 0 {
let pgm_base = pgc_offset + pgm_map_offset;
// Collect cell durations
let mut cell_durations = Vec::with_capacity(num_cells);
let cell_base = pgc_offset + cell_playback_offset;
for i in 0..num_cells {
let co = cell_base + i * 24;
if co + 8 <= data.len() {
cell_durations.push(bcd_to_secs(&data[co + 4..co + 8]));
} else {
cell_durations.push(0.0);
}
}
// Program map: each byte is the first cell number (1-based) for that program
for p in 0..nr_of_programs {
if pgm_base + p >= data.len() {
break;
}
let first_cell = data[pgm_base + p] as usize;
// Chapter time = sum of cell durations before this program's first cell
let time: f64 = cell_durations[..first_cell.saturating_sub(1)].iter().sum();
times.push(time);
}
}
times
};
// Extract subtitle palette at PGC offset 0xA4: 16 colors × 4 bytes [padding, Y, Cb, Cr]
let palette = if pgc_offset + 0xA4 + 64 <= data.len() {
let mut colors = Vec::with_capacity(16);
@@ -562,6 +597,7 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
chapters,
duration_secs,
cells,
chapter_times,
palette,
})
}
@@ -651,6 +687,7 @@ mod tests {
chapters: 5,
duration_secs: 3600.0,
cells: vec![cell.clone()],
chapter_times: Vec::new(),
palette: None,
};
assert_eq!(title.chapters, 5);
@@ -697,12 +734,12 @@ mod tests {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1; // 1 program
pgc[0x03] = 2; // 2 cells
// 1h 59m 30s at 29.97fps, 0 frames
// 1h 59m 30s at 29.97fps, 0 frames
pgc[0x04] = 0x01; // hours BCD
pgc[0x05] = 0x59; // minutes BCD
pgc[0x06] = 0x30; // seconds BCD
pgc[0x07] = 0b11_000000; // 29.97fps, 0 frames
// Cell playback info offset at PGC+0xE8
// Cell playback info offset at PGC+0xE8
let cell_offset: u16 = 0xEA; // right after minimum header
pgc[0xE8] = (cell_offset >> 8) as u8;
pgc[0xE9] = cell_offset as u8;
@@ -710,17 +747,32 @@ mod tests {
pgc.resize(pgc.len() + 48, 0);
// Cell 0: sectors 100-200
let co = 0xEA;
pgc[co + 8] = 0; pgc[co + 9] = 0; pgc[co + 10] = 0; pgc[co + 11] = 100; // first sector
pgc[co + 20] = 0; pgc[co + 21] = 0; pgc[co + 22] = 0; pgc[co + 23] = 200; // last sector
// Cell 1: sectors 300-400
pgc[co + 8] = 0;
pgc[co + 9] = 0;
pgc[co + 10] = 0;
pgc[co + 11] = 100; // first sector
pgc[co + 20] = 0;
pgc[co + 21] = 0;
pgc[co + 22] = 0;
pgc[co + 23] = 200; // last sector
// Cell 1: sectors 300-400
let co = 0xEA + 24;
pgc[co + 8] = 0; pgc[co + 9] = 0; pgc[co + 10] = 1; pgc[co + 11] = 44; // first sector = 300
pgc[co + 20] = 0; pgc[co + 21] = 0; pgc[co + 22] = 1; pgc[co + 23] = 144; // last sector = 400
pgc[co + 8] = 0;
pgc[co + 9] = 0;
pgc[co + 10] = 1;
pgc[co + 11] = 44; // first sector = 300
pgc[co + 20] = 0;
pgc[co + 21] = 0;
pgc[co + 22] = 1;
pgc[co + 23] = 144; // last sector = 400
let title = parse_pgc(&pgc, 0, 5).unwrap();
let expected = 1.0 * 3600.0 + 59.0 * 60.0 + 30.0;
assert!((title.duration_secs - expected).abs() < 0.1,
"expected ~{expected}s, got {}s", title.duration_secs);
assert!(
(title.duration_secs - expected).abs() < 0.1,
"expected ~{expected}s, got {}s",
title.duration_secs
);
assert_eq!(title.chapters, 5);
assert_eq!(title.cells.len(), 2);
assert_eq!(title.cells[0].first_sector, 100);
+122
View File
@@ -138,6 +138,128 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
}
}
/// Fill in default labels for any streams that don't have one.
/// Runs after BD-J label extraction — fills gaps with codec + channel descriptions.
/// This is the central place for all fallback label generation.
pub fn fill_defaults(titles: &mut [crate::disc::DiscTitle]) {
use crate::disc::Stream;
for title in titles.iter_mut() {
for stream in &mut title.streams {
match stream {
Stream::Audio(a) if a.label.is_empty() => {
a.label = generate_audio_label(&a.codec, &a.channels, a.secondary);
}
Stream::Video(v) if v.label.is_empty() => {
v.label =
generate_video_label(&v.codec, v.resolution.pixels(), &v.hdr, v.secondary);
}
Stream::Subtitle(s) if s.forced => {
// Ensure forced subs are labeled even if BD-J didn't set a name
// (subtitle labels are generally not set — this just marks forced)
}
_ => {}
}
}
}
}
fn generate_video_label(
codec: &crate::disc::Codec,
pixels: (u32, u32),
hdr: &crate::disc::HdrFormat,
secondary: bool,
) -> String {
use crate::disc::HdrFormat;
if secondary {
return match hdr {
HdrFormat::DolbyVision => "Dolby Vision EL".to_string(),
_ => "Secondary Video".to_string(),
};
}
let mut parts = Vec::new();
// Codec
parts.push(codec.name().to_string());
// Resolution
let (w, h) = pixels;
let res = if w >= 7680 {
"8K"
} else if w >= 3840 {
"4K"
} else if w >= 1920 {
"1080p"
} else if w >= 1280 {
"720p"
} else if h >= 576 {
"576p"
} else if h >= 480 {
"480p"
} else {
""
};
if !res.is_empty() {
parts.push(res.into());
}
// HDR
match hdr {
HdrFormat::Sdr => {}
_ => parts.push(hdr.name().to_string()),
}
parts.join(" ")
}
fn generate_audio_label(
codec: &crate::disc::Codec,
channels: &crate::disc::AudioChannels,
secondary: bool,
) -> String {
use crate::disc::{AudioChannels, Codec};
// Full marketing names for disc audio codecs
let codec_name = match codec {
Codec::TrueHd => "Dolby TrueHD",
Codec::Ac3 => "Dolby Digital",
Codec::Ac3Plus => "Dolby Digital Plus",
Codec::DtsHdMa => "DTS-HD Master Audio",
Codec::DtsHdHr => "DTS-HD High Resolution",
Codec::Dts => "DTS",
Codec::Lpcm => "LPCM",
Codec::Aac => "AAC",
Codec::Mp2 => "MPEG Audio",
Codec::Mp3 => "MP3",
Codec::Flac => "FLAC",
Codec::Opus => "Opus",
_ => return String::new(),
};
// Channel layout
let channel_str = match channels {
AudioChannels::Mono => "1.0",
AudioChannels::Stereo => "2.0",
AudioChannels::Stereo21 => "2.1",
AudioChannels::Quad => "4.0",
AudioChannels::Surround50 => "5.0",
AudioChannels::Surround51 => "5.1",
AudioChannels::Surround61 => "6.1",
AudioChannels::Surround71 => "7.1",
AudioChannels::Unknown => "",
};
let suffix = if secondary { " (Secondary)" } else { "" };
if channel_str.is_empty() {
format!("{}{}", codec_name, suffix)
} else {
format!("{} {}{}", codec_name, channel_str, suffix)
}
}
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
for (_name, detect, parse) in PARSERS {
if detect(udf) {
+3 -2
View File
@@ -92,6 +92,7 @@ pub mod scsi;
pub mod sector;
pub(crate) mod speed;
pub(crate) mod udf;
pub mod verify;
pub use drive::capture::{
capture_drive_data, mask_bytes, mask_string, CapturedFeature, DriveCapture,
@@ -104,8 +105,8 @@ pub use profile::DriveProfile;
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
pub use decrypt::{decrypt_sectors, DecryptKeys};
pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat,
Disc, DiscFormat, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
ScanOptions, Stream, SubtitleStream, VideoStream,
};
pub use mux::DiscStream;
+1 -1
View File
@@ -256,7 +256,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
// Parse PlayListMark section
let mut marks = Vec::new();
if mark_start > 0 && mark_start + 4 < data.len() {
if mark_start > 0 && mark_start + 4 <= data.len() {
let ms = &data[mark_start..];
if ms.len() >= 6 {
let num_marks = u16::from_be_bytes([ms[4], ms[5]]) as usize;
+186 -264
View File
@@ -1,15 +1,15 @@
//! AC3 (Dolby Digital) / EAC3 (Dolby Digital Plus) frame parser.
//!
//! AC3 frames are self-contained and always start with syncword 0x0B77.
//! Each PES packet typically contains exactly one AC3 frame.
//! All AC3 frames are effectively keyframes (no inter-frame dependencies).
//!
//! E-AC-3 shares the same syncword but uses bsid >= 11 (typically 16).
//! Frame size is derived from the frmsiz field instead of fscod/frmsizecod.
//! Buffers across PES boundaries so frames that span two PES packets
//! are emitted complete, not truncated.
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
pub struct Ac3Parser;
pub struct Ac3Parser {
/// Leftover bytes from previous PES (incomplete frame at end).
buf: Vec<u8>,
}
impl Default for Ac3Parser {
fn default() -> Self {
@@ -19,19 +19,24 @@ impl Default for Ac3Parser {
impl Ac3Parser {
pub fn new() -> Self {
Self
Self {
buf: Vec::with_capacity(4096),
}
}
}
impl CodecParser for Ac3Parser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.len() < 2 {
if pes.data.is_empty() {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
let data = &pes.data;
// Prepend leftover from previous PES
self.buf.extend_from_slice(&pes.data);
let data = &self.buf;
let mut frames = Vec::new();
let mut pos = 0;
@@ -44,44 +49,54 @@ impl CodecParser for Ac3Parser {
let remaining = &data[start..];
// Need at least 6 bytes to inspect bsid / frame size fields
if remaining.len() < 6 {
// Emit whatever remains as a single frame
frames.push(Frame {
pts_ns,
keyframe: true,
data: remaining.to_vec(),
});
// Not enough data to determine frame size — keep for next PES
break;
}
let bsid = get_bsid(remaining);
if bsid >= 11 {
// E-AC-3 frame size from frmsiz field (bytes 2-3)
let frame_size = eac3_frame_size(remaining);
let end = start + frame_size.min(data.len() - start);
frames.push(Frame {
pts_ns,
keyframe: true,
data: data[start..end].to_vec(),
});
pos = end;
let frame_size = if bsid >= 11 {
eac3_frame_size(remaining)
} else {
// AC-3: emit everything from syncword to next syncword (or end)
let next_sync = find_ac3_sync(&data[start + 2..]).map(|o| start + 2 + o);
let end = next_sync.unwrap_or(data.len());
frames.push(Frame {
pts_ns,
keyframe: true,
data: data[start..end].to_vec(),
});
pos = end;
ac3_frame_size(remaining)
};
if frame_size == 0 || frame_size > 8192 {
// Invalid frame size — skip this sync word
pos = start + 2;
continue;
}
if start + frame_size > data.len() {
// Incomplete frame — keep for next PES
break;
}
frames.push(Frame {
pts_ns,
keyframe: true,
data: data[start..start + frame_size].to_vec(),
});
pos = start + frame_size;
}
// Keep unconsumed data for next call
// `pos` points to the start of unconsumed data (either a partial sync or leftover)
let keep_from = if pos < data.len() {
// Find the last sync word position in the unconsumed region
find_ac3_sync(&data[pos..])
.map(|o| pos + o)
.unwrap_or(data.len())
} else {
data.len()
};
if keep_from < data.len() {
self.buf = data[keep_from..].to_vec();
} else {
self.buf.clear();
}
// If we found no syncword at all, return empty — the data is not valid AC3.
frames
}
@@ -97,7 +112,6 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> {
/// Extract bsid from an AC-3/E-AC-3 frame starting at the syncword.
/// bsid is at byte 5, bits 7..3.
/// AC-3: bsid <= 10, E-AC-3: bsid >= 11 (typically 16).
pub fn get_bsid(data: &[u8]) -> u8 {
if data.len() < 6 {
return 0;
@@ -106,258 +120,166 @@ pub fn get_bsid(data: &[u8]) -> u8 {
}
/// Calculate E-AC-3 frame size in bytes from the frmsiz field.
/// frmsiz is at bits [2:0] of byte 2 concatenated with byte 3.
/// Frame size = (frmsiz + 1) * 2 bytes.
pub fn eac3_frame_size(data: &[u8]) -> usize {
fn eac3_frame_size(data: &[u8]) -> usize {
if data.len() < 4 {
return 0;
}
let frmsiz = ((data[2] as usize & 0x07) << 8) | (data[3] as usize);
let frmsiz = ((data[2] as usize & 0x07) << 8) | data[3] as usize;
(frmsiz + 1) * 2
}
/// Calculate AC-3 frame size in bytes from fscod and frmsizecod.
fn ac3_frame_size(data: &[u8]) -> usize {
if data.len() < 5 {
return 0;
}
let fscod = (data[4] >> 6) & 0x03;
let frmsizecod = (data[4] & 0x3F) as usize;
if frmsizecod >= AC3_FRAME_SIZES.len() {
return 0;
}
let words = AC3_FRAME_SIZES[frmsizecod];
match fscod {
0 => words[0] * 2,
1 => words[1] * 2,
2 => words[2] * 2,
_ => 0,
}
}
/// AC-3 frame size table: [frmsizecod] -> [48kHz words, 44.1kHz words, 32kHz words]
const AC3_FRAME_SIZES: [[usize; 3]; 38] = [
[64, 69, 96],
[64, 70, 96],
[80, 87, 120],
[80, 88, 120],
[96, 104, 144],
[96, 105, 144],
[112, 121, 168],
[112, 122, 168],
[128, 139, 192],
[128, 140, 192],
[160, 174, 240],
[160, 175, 240],
[192, 208, 288],
[192, 209, 288],
[224, 243, 336],
[224, 244, 336],
[256, 278, 384],
[256, 279, 384],
[320, 348, 480],
[320, 349, 480],
[384, 417, 576],
[384, 418, 576],
[448, 487, 672],
[448, 488, 672],
[512, 557, 768],
[512, 558, 768],
[640, 696, 960],
[640, 697, 960],
[768, 835, 1152],
[768, 836, 1152],
[896, 975, 1344],
[896, 976, 1344],
[1024, 1114, 1536],
[1024, 1115, 1536],
[1152, 1253, 1728],
[1152, 1254, 1728],
[1280, 1393, 1920],
[1280, 1394, 1920],
];
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
pid: 0x1100,
pts,
dts: None,
data,
}
fn make_ac3_frame(fscod: u8, frmsizecod: u8) -> Vec<u8> {
let size = AC3_FRAME_SIZES[frmsizecod as usize][fscod as usize] * 2;
let mut frame = vec![0u8; size];
frame[0] = 0x0B;
frame[1] = 0x77;
frame[4] = (fscod << 6) | frmsizecod;
frame[5] = 0x08 << 3; // bsid = 8 (AC-3)
frame
}
/// Build a minimal AC-3 header (bsid <= 10).
fn make_ac3_header(bsid: u8) -> Vec<u8> {
// 0x0B 0x77 <byte2> <byte3> <byte4> <byte5=bsid>
let byte5 = (bsid & 0x1F) << 3;
vec![0x0B, 0x77, 0x00, 0x00, 0x00, byte5, 0xAA, 0xBB]
}
/// Build a minimal E-AC-3 header with the given bsid and frmsiz.
/// frmsiz encodes frame size: frame_bytes = (frmsiz + 1) * 2.
fn make_eac3_header(bsid: u8, frmsiz: u16, payload_fill: u8) -> Vec<u8> {
let byte2 = (frmsiz >> 8) as u8 & 0x07;
let byte3 = (frmsiz & 0xFF) as u8;
let byte5 = (bsid & 0x1F) << 3;
let frame_size = (frmsiz as usize + 1) * 2;
let mut data = vec![0x0B, 0x77, byte2, byte3, 0x00, byte5];
// Pad to full frame size
while data.len() < frame_size {
data.push(payload_fill);
}
data.truncate(frame_size);
data
}
// --- syncword detection ---
#[test]
fn find_ac3_sync_at_start() {
let data = [0x0B, 0x77, 0x01, 0x02, 0x03];
assert_eq!(find_ac3_sync(&data), Some(0));
}
#[test]
fn find_ac3_sync_with_garbage_prefix() {
let data = [0xFF, 0xFE, 0x0B, 0x77, 0x01, 0x02];
assert_eq!(find_ac3_sync(&data), Some(2));
}
#[test]
fn find_ac3_sync_none() {
let data = [0x0B, 0x78, 0x00, 0x00];
assert_eq!(find_ac3_sync(&data), None);
}
#[test]
fn find_ac3_sync_empty() {
let data: [u8; 0] = [];
assert_eq!(find_ac3_sync(&data), None);
}
// --- bsid detection ---
#[test]
fn bsid_ac3() {
let header = make_ac3_header(8);
assert_eq!(get_bsid(&header), 8);
}
#[test]
fn bsid_eac3() {
let header = make_eac3_header(16, 99, 0x00);
assert_eq!(get_bsid(&header), 16);
}
#[test]
fn bsid_boundary_10() {
let header = make_ac3_header(10);
assert_eq!(get_bsid(&header), 10);
// bsid 10 should be treated as AC-3 (<= 10)
assert!(get_bsid(&header) <= 10);
}
#[test]
fn bsid_boundary_11() {
let header = make_eac3_header(11, 3, 0x00);
assert_eq!(get_bsid(&header), 11);
// bsid 11 should be treated as E-AC-3 (>= 11)
assert!(get_bsid(&header) >= 11);
}
// --- E-AC-3 frame size calculation ---
#[test]
fn eac3_frame_size_basic() {
// frmsiz = 99 → frame_size = (99+1)*2 = 200 bytes
let header = make_eac3_header(16, 99, 0xDD);
assert_eq!(eac3_frame_size(&header), 200);
}
#[test]
fn eac3_frame_size_min() {
// frmsiz = 0 → frame_size = (0+1)*2 = 2 bytes
let data = [0x0B, 0x77, 0x00, 0x00, 0x00, 0x80];
assert_eq!(eac3_frame_size(&data), 2);
}
#[test]
fn eac3_frame_size_large() {
// frmsiz = 0x7FF (max 11-bit) → (2047+1)*2 = 4096
let data = [0x0B, 0x77, 0x07, 0xFF, 0x00, 0x80];
assert_eq!(eac3_frame_size(&data), 4096);
}
// --- parse: E-AC-3 frame extraction ---
#[test]
fn parse_eac3_single_frame() {
let mut parser = Ac3Parser::new();
// frmsiz = 9 → frame_size = 20 bytes
let data = make_eac3_header(16, 9, 0xCC);
assert_eq!(data.len(), 20);
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data.len(), 20);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
assert!(frames[0].keyframe);
}
#[test]
fn parse_eac3_frame_with_garbage_prefix() {
let mut parser = Ac3Parser::new();
let mut data = vec![0xFF, 0xFE]; // garbage
data.extend_from_slice(&make_eac3_header(16, 4, 0xAA)); // frmsiz=4 → 10 bytes
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data[0], 0x0B);
assert_eq!(frames[0].data[1], 0x77);
assert_eq!(frames[0].data.len(), 10);
}
// --- parse syncword → frame extracted (AC-3) ---
#[test]
fn parse_syncword() {
let mut parser = Ac3Parser::new();
// AC3 frame starting with syncword (bsid=8)
let data = make_ac3_header(8);
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn parse_syncword_with_garbage_prefix() {
let mut parser = Ac3Parser::new();
// Garbage bytes before syncword
let mut data = vec![0xFF, 0xFE];
data.extend_from_slice(&make_ac3_header(8));
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data[0], 0x0B);
assert_eq!(frames[0].data[1], 0x77);
}
// --- all frames are keyframes ---
#[test]
fn all_keyframes() {
let mut parser = Ac3Parser::new();
for i in 0..5u8 {
let mut data = make_ac3_header(8);
data.push(i);
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "AC3 frame {} should be a keyframe", i);
}
}
// --- codec_private is None ---
#[test]
fn codec_private_none() {
let parser = Ac3Parser::new();
assert!(parser.codec_private().is_none());
}
// --- empty / too-short PES ---
#[test]
fn parse_empty_pes() {
let mut parser = Ac3Parser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
let pes = PesPacket {
pid: 0,
pts: None,
dts: None,
data: vec![],
};
assert!(parser.parse(&pes).is_empty());
}
#[test]
fn parse_single_byte_pes() {
fn parse_single_frame() {
let mut parser = Ac3Parser::new();
let pes = make_pes(vec![0x0B], Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = Ac3Parser::new();
let data = make_ac3_header(8);
// 45000 ticks = 0.5 seconds → 500_000_000 ns
let pes = make_pes(data, Some(45000));
let frame_data = make_ac3_frame(0, 2); // 48kHz, 80 words = 160 bytes
let pes = PesPacket {
pid: 0,
pts: Some(90000),
dts: None,
data: frame_data.clone(),
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 500_000_000);
assert_eq!(frames[0].data.len(), 160);
}
// --- None PTS ---
#[test]
fn parse_frame_spanning_two_pes() {
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2); // 160 bytes
let mid = 80;
// First PES: first half of frame
let pes1 = PesPacket {
pid: 0,
pts: Some(90000),
dts: None,
data: frame_data[..mid].to_vec(),
};
let frames1 = parser.parse(&pes1);
assert!(frames1.is_empty(), "partial frame should not emit");
// Second PES: second half
let pes2 = PesPacket {
pid: 0,
pts: Some(93000),
dts: None,
data: frame_data[mid..].to_vec(),
};
let frames2 = parser.parse(&pes2);
assert_eq!(frames2.len(), 1);
assert_eq!(frames2[0].data.len(), 160);
}
#[test]
fn no_pts() {
fn skip_garbage_before_sync() {
let mut parser = Ac3Parser::new();
let data = make_ac3_header(8);
let pes = make_pes(data, None);
let frame_data = make_ac3_frame(0, 2);
let mut data = vec![0xDE, 0xAD, 0xBE, 0xEF]; // garbage
data.extend_from_slice(&frame_data);
let pes = PesPacket {
pid: 0,
pts: None,
dts: None,
data,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 0);
assert_eq!(frames[0].data.len(), 160);
}
#[test]
fn ac3_frame_size_table() {
// fscod=0 (48kHz), frmsizecod=0: 64 words = 128 bytes
assert_eq!(ac3_frame_size(&[0x0B, 0x77, 0, 0, 0x00, 0x40]), 128);
// fscod=0 (48kHz), frmsizecod=2: 80 words = 160 bytes
assert_eq!(ac3_frame_size(&[0x0B, 0x77, 0, 0, 0x02, 0x40]), 160);
}
}
+127 -196
View File
@@ -2,16 +2,17 @@
//!
//! DTS core syncword: 0x7FFE8001 (32 bits).
//! DTS-HD MA/HRA extension syncword: 0x64582025 (32 bits), appears after the core frame.
//! The extension contains high-resolution audio data and is appended to the core frame.
//! All frames are keyframes (no inter-frame dependencies).
//! Each PES packet = one frame.
//! Buffers across PES boundaries so frames spanning two PES packets
//! are emitted complete.
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
/// DTS-HD extension syncword bytes.
const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
pub struct DtsParser;
pub struct DtsParser {
buf: Vec<u8>,
}
impl Default for DtsParser {
fn default() -> Self {
@@ -21,7 +22,9 @@ impl Default for DtsParser {
impl DtsParser {
pub fn new() -> Self {
Self
Self {
buf: Vec::with_capacity(32768),
}
}
}
@@ -32,31 +35,77 @@ impl CodecParser for DtsParser {
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
let data = &pes.data;
self.buf.extend_from_slice(&pes.data);
// Look for a DTS-HD extension substream after the core.
// If found, include both core + extension in the output frame.
let frame_data = match find_dts_hd_ext_sync(data) {
Some(ext_offset) => {
let ext = &data[ext_offset..];
if ext.len() >= 9 {
let ext_size = dts_hd_ext_frame_size(ext);
let total_end = ext_offset + ext_size;
let end = total_end.min(data.len());
data[..end].to_vec()
} else {
// Extension header too short to parse size; include all data.
data.to_vec()
let data = &self.buf;
let mut frames = Vec::new();
let mut pos = 0;
while pos < data.len() {
// Find DTS core sync
let start = match find_sync(&data[pos..], &DTS_CORE_SYNC) {
Some(offset) => pos + offset,
None => break,
};
// Need at least 10 bytes for core header to get frame size
if start + 10 > data.len() {
break;
}
let core_size = dts_core_frame_size(&data[start..]);
if core_size == 0 || core_size > 32768 {
pos = start + 4;
continue;
}
if start + core_size > data.len() {
// Incomplete core frame
break;
}
// Check for DTS-HD extension after core
let mut total_size = core_size;
if start + core_size + 4 <= data.len() {
if let Some(0) = find_sync(
&data[start + core_size..start + core_size + 4],
&DTS_HD_EXT_SYNC,
) {
let ext = &data[start + core_size..];
if ext.len() >= 9 {
let ext_size = dts_hd_ext_frame_size(ext);
if start + core_size + ext_size <= data.len() {
total_size = core_size + ext_size;
}
// If ext incomplete, just emit core
}
}
}
None => data.to_vec(),
frames.push(Frame {
pts_ns,
keyframe: true,
data: data[start..start + total_size].to_vec(),
});
pos = start + total_size;
}
// Keep unconsumed data
let keep_from = if pos < data.len() {
find_sync(&data[pos..], &DTS_CORE_SYNC)
.map(|o| pos + o)
.unwrap_or(data.len())
} else {
data.len()
};
vec![Frame {
pts_ns,
keyframe: true,
data: frame_data,
}]
if keep_from < data.len() {
self.buf = data[keep_from..].to_vec();
} else {
self.buf.clear();
}
frames
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -64,23 +113,27 @@ impl CodecParser for DtsParser {
}
}
/// Find the DTS-HD extension syncword (0x64582025) in data.
/// Returns the byte offset of the sync, or None.
pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
fn find_sync(data: &[u8], pattern: &[u8; 4]) -> Option<usize> {
if data.len() < 4 {
return None;
}
(0..=data.len() - 4).find(|&i| {
data[i] == DTS_HD_EXT_SYNC[0]
&& data[i + 1] == DTS_HD_EXT_SYNC[1]
&& data[i + 2] == DTS_HD_EXT_SYNC[2]
&& data[i + 3] == DTS_HD_EXT_SYNC[3]
})
(0..=data.len() - 4).find(|&i| data[i..i + 4] == *pattern)
}
/// Calculate DTS-HD extension frame size from the extension header.
/// The size field is at bytes 6-8 of the extension:
/// ((ext[6] & 0x1F) << 11) | (ext[7] << 3) | (ext[8] >> 5) + 1
/// DTS core frame size from header bits.
/// fsize is at bits 46-59 (14 bits) of the header: bytes 5-7.
fn dts_core_frame_size(data: &[u8]) -> usize {
if data.len() < 10 {
return 0;
}
// fsize field: 14 bits starting at bit 46
// byte 5 bits 1-0, byte 6 all 8, byte 7 bits 7-4
let fsize =
((data[5] as usize & 0x03) << 12) | ((data[6] as usize) << 4) | ((data[7] as usize) >> 4);
fsize + 1
}
/// DTS-HD extension frame size from extension header.
pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize {
if ext.len() < 9 {
return 0;
@@ -90,6 +143,10 @@ pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize {
raw + 1
}
pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
find_sync(data, &DTS_HD_EXT_SYNC)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -104,164 +161,16 @@ mod tests {
}
}
/// Build a DTS core frame with given payload size.
fn make_dts_core(payload_len: usize) -> Vec<u8> {
let mut data = vec![0x7F, 0xFE, 0x80, 0x01];
data.resize(4 + payload_len, 0xAA);
fn make_dts_core(size: usize) -> Vec<u8> {
let fsize = size - 1;
let mut data = vec![0u8; size];
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
data[5] = (data[5] & 0xFC) | ((fsize >> 12) & 0x03) as u8;
data[6] = ((fsize >> 4) & 0xFF) as u8;
data[7] = (data[7] & 0x0F) | (((fsize & 0x0F) << 4) as u8);
data
}
/// Build a DTS-HD extension header + payload.
/// ext_size is the value to encode (frame size = ext_size + 1 reported by dts_hd_ext_frame_size,
/// but we encode raw = ext_size so that dts_hd_ext_frame_size returns ext_size + 1).
fn make_dts_hd_ext(raw_size_field: usize, payload_fill: u8) -> Vec<u8> {
let total = raw_size_field + 1; // the size dts_hd_ext_frame_size will return
let byte6 = ((raw_size_field >> 11) & 0x1F) as u8;
let byte7 = ((raw_size_field >> 3) & 0xFF) as u8;
let byte8 = ((raw_size_field & 0x07) << 5) as u8;
let mut data = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, byte6, byte7, byte8];
while data.len() < total {
data.push(payload_fill);
}
data.truncate(total);
data
}
// --- DTS-HD extension sync detection ---
#[test]
fn find_ext_sync_at_offset() {
let mut data = vec![0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00];
data.extend_from_slice(&[0x64, 0x58, 0x20, 0x25]);
assert_eq!(find_dts_hd_ext_sync(&data), Some(6));
}
#[test]
fn find_ext_sync_none() {
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00];
assert_eq!(find_dts_hd_ext_sync(&data), None);
}
#[test]
fn find_ext_sync_at_start() {
let data = vec![0x64, 0x58, 0x20, 0x25, 0x00];
assert_eq!(find_dts_hd_ext_sync(&data), Some(0));
}
#[test]
fn find_ext_sync_too_short() {
let data = vec![0x64, 0x58, 0x20];
assert_eq!(find_dts_hd_ext_sync(&data), None);
}
// --- DTS-HD extension frame size ---
#[test]
fn ext_frame_size_basic() {
// raw_size_field = 100 → frame size = 101
let ext = make_dts_hd_ext(100, 0xBB);
assert_eq!(dts_hd_ext_frame_size(&ext), 101);
}
#[test]
fn ext_frame_size_zero() {
// raw_size_field = 0 → frame size = 1
let ext = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00];
assert_eq!(dts_hd_ext_frame_size(&ext), 1);
}
#[test]
fn ext_frame_size_large() {
// raw = 0x1F << 11 | 0xFF << 3 | 0x07 = 0xFFFF = 65535
// frame_size = 65536
let ext = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x1F, 0xFF, 0xFF];
// byte6=0x1F, byte7=0xFF, byte8=0xFF
// (0x1F << 11) | (0xFF << 3) | (0xFF >> 5) = 63488 | 2040 | 7 = 65535
assert_eq!(dts_hd_ext_frame_size(&ext), 65536);
}
// --- parse: core + extension frame ---
#[test]
fn parse_core_plus_extension() {
let mut parser = DtsParser::new();
let core = make_dts_core(20); // 24 bytes total
let ext = make_dts_hd_ext(50, 0xCC); // 51 bytes
let mut data = core.clone();
data.extend_from_slice(&ext);
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Frame should include core (24) + extension (51) = 75 bytes
assert_eq!(frames[0].data.len(), 24 + 51);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
assert!(frames[0].keyframe);
}
#[test]
fn parse_core_only() {
let mut parser = DtsParser::new();
let data = make_dts_core(10);
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
}
#[test]
fn parse_core_plus_extension_truncated_at_buffer_end() {
let mut parser = DtsParser::new();
let core = make_dts_core(4); // 8 bytes
// Extension claims 200 bytes but we only provide 20
let ext = make_dts_hd_ext(199, 0xDD); // wants 200 bytes
let mut data = core;
// Only append partial extension (first 20 bytes)
data.extend_from_slice(&ext[..20.min(ext.len())]);
let total_len = data.len();
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Should be clamped to actual data length
assert_eq!(frames[0].data.len(), total_len);
}
// --- basic tests (carried over) ---
#[test]
fn parse_basic_frame() {
let mut parser = DtsParser::new();
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn all_keyframes() {
let mut parser = DtsParser::new();
for i in 0..3 {
let data = vec![0x7F, 0xFE, 0x80, 0x01, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "DTS frame should always be keyframe");
}
}
#[test]
fn codec_private_none() {
let parser = DtsParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn parse_empty_pes() {
let mut parser = DtsParser::new();
@@ -270,11 +179,33 @@ mod tests {
}
#[test]
fn no_pts() {
fn parse_single_frame() {
let mut parser = DtsParser::new();
let pes = make_pes(vec![0x7F, 0xFE, 0x80, 0x01], None);
let frame = make_dts_core(512);
let pes = make_pes(frame, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 0);
assert_eq!(frames[0].data.len(), 512);
}
#[test]
fn parse_frame_spanning_two_pes() {
let mut parser = DtsParser::new();
let frame = make_dts_core(512);
let mid = 256;
let pes1 = make_pes(frame[..mid].to_vec(), Some(90000));
assert!(parser.parse(&pes1).is_empty());
let pes2 = make_pes(frame[mid..].to_vec(), Some(93000));
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data.len(), 512);
}
#[test]
fn codec_private_none() {
let parser = DtsParser::new();
assert!(parser.codec_private().is_none());
}
}
+7 -18
View File
@@ -14,19 +14,8 @@ pub struct DvdSubParser {
codec_data: Option<Vec<u8>>,
}
impl Default for DvdSubParser {
fn default() -> Self {
Self::new()
}
}
impl DvdSubParser {
pub fn new() -> Self {
Self { codec_data: None }
}
/// Create a parser with pre-formatted codec private data (palette header).
pub fn with_codec_data(codec_data: Option<Vec<u8>>) -> Self {
pub fn new(codec_data: Option<Vec<u8>>) -> Self {
Self { codec_data }
}
}
@@ -109,7 +98,7 @@ mod tests {
#[test]
fn passthrough_data() {
let mut parser = DvdSubParser::new();
let mut parser = DvdSubParser::new(None);
let sub_data = vec![0x00, 0x0A, 0x00, 0x08, 0x01, 0xFF, 0x02, 0x03, 0x04, 0x05];
let pes = make_pes(sub_data.clone(), Some(90000));
let frames = parser.parse(&pes);
@@ -124,7 +113,7 @@ mod tests {
#[test]
fn always_keyframe() {
let mut parser = DvdSubParser::new();
let mut parser = DvdSubParser::new(None);
for i in 0..3u8 {
let data = vec![0x00, i, 0x00, i + 1];
let pes = make_pes(data, Some(90000 * i as i64));
@@ -139,21 +128,21 @@ mod tests {
#[test]
fn empty_pes_returns_no_frames() {
let mut parser = DvdSubParser::new();
let mut parser = DvdSubParser::new(None);
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
#[test]
fn codec_private_none_by_default() {
let parser = DvdSubParser::new();
let parser = DvdSubParser::new(None);
assert!(parser.codec_private().is_none());
}
#[test]
fn codec_private_returns_palette_when_set() {
let palette_data = b"palette: 000000, ffffff\n".to_vec();
let parser = DvdSubParser::with_codec_data(Some(palette_data.clone()));
let parser = DvdSubParser::new(Some(palette_data.clone()));
let cp = parser.codec_private();
assert!(cp.is_some());
assert_eq!(cp.unwrap(), palette_data);
@@ -161,7 +150,7 @@ mod tests {
#[test]
fn no_pts_defaults_to_zero() {
let mut parser = DvdSubParser::new();
let mut parser = DvdSubParser::new(None);
let pes = make_pes(vec![0x01, 0x02], None);
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
+9 -3
View File
@@ -69,9 +69,15 @@ impl CodecParser for HevcParser {
let nal_type = (data[nal_start] >> 1) & 0x3F;
match nal_type {
NAL_VPS => { self.vps = Some(data[nal_start..end].to_vec()); }
NAL_SPS => { self.sps = Some(data[nal_start..end].to_vec()); }
NAL_PPS => { self.pps = Some(data[nal_start..end].to_vec()); }
NAL_VPS => {
self.vps = Some(data[nal_start..end].to_vec());
}
NAL_SPS => {
self.sps = Some(data[nal_start..end].to_vec());
}
NAL_PPS => {
self.pps = Some(data[nal_start..end].to_vec());
}
NAL_AUD => {} // Skip access unit delimiters
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
keyframe = true;
+2 -10
View File
@@ -79,18 +79,10 @@ impl CodecParser for PassthroughParser {
}
}
/// Create the appropriate parser for a codec.
pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> {
parser_for_codec_with_data(codec, None)
}
/// Create the appropriate parser for a codec, with optional codec private data.
///
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
pub fn parser_for_codec_with_data(
codec: Codec,
codec_data: Option<Vec<u8>>,
) -> Box<dyn CodecParser> {
pub fn parser_for_codec(codec: Codec, codec_data: Option<Vec<u8>>) -> Box<dyn CodecParser> {
match codec {
Codec::H264 => Box::new(h264::H264Parser::new()),
Codec::Hevc => Box::new(hevc::HevcParser::new()),
@@ -101,7 +93,7 @@ pub fn parser_for_codec_with_data(
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
Codec::Pgs => Box::new(pgs::PgsParser::new()),
Codec::Lpcm => Box::new(lpcm::LpcmParser::new()),
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::with_codec_data(codec_data)),
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new(codec_data)),
_ => Box::new(PassthroughParser::new(true)),
}
}
+65 -15
View File
@@ -50,6 +50,8 @@ const ASPECT_RATIOS: [(u8, u8); 5] = [
pub struct Mpeg2Parser {
/// Raw bytes of the last seen sequence header (+ sequence extension if found).
seq_header: Option<Vec<u8>>,
/// Whether we've captured the sequence extension (B5) already.
has_extension: bool,
}
impl Default for Mpeg2Parser {
@@ -60,7 +62,10 @@ impl Default for Mpeg2Parser {
impl Mpeg2Parser {
pub fn new() -> Self {
Self { seq_header: None }
Self {
seq_header: None,
has_extension: false,
}
}
/// Extract resolution from a captured sequence header.
@@ -105,23 +110,68 @@ impl CodecParser for Mpeg2Parser {
match code {
SEQ_HEADER_CODE => {
// Capture sequence header: from start code to next start code
// (or to the sequence extension if present).
// MPEG-2 sequence header: 00 00 01 B3 + variable data.
// Base header: 8 bytes after start code = 12 bytes total.
// Then possibly 64 intra quantizer values (bit-packed from bit 63).
// Then possibly 64 non-intra quantizer values.
// Then extensions (00 00 01 B5).
//
// Capture to the next start code within this PES data.
// If no next start code exists (extension in next PES), capture
// just the sequence header without extensions.
let hdr_start = sc;
let hdr_end = find_start_code(data, sc + 4).unwrap_or(data.len());
let next_sc = find_start_code(data, sc + 4);
let hdr_end = match next_sc {
Some(next) if next + 3 < data.len() => {
let mut end = next;
// Include B5 extensions
while end + 3 < data.len() && data[end + 3] == SEQ_EXT_CODE {
end = find_start_code(data, end + 4).unwrap_or(data.len());
}
end
}
_ => {
// No next start code in this PES — calculate exact header size.
// Bit 62: load_intra_quantiser_matrix
// Bit 62+1+512: load_non_intra_quantiser_matrix (if intra present)
// Bit 62+1: load_non_intra_quantiser_matrix (if intra absent)
if sc + 12 > data.len() {
data.len()
} else {
let mut bits = 63u32; // bits consumed so far
let intra = (data[sc + 11] & 0x02) != 0;
if intra {
bits += 64 * 8;
}
// Non-intra flag is at current bit position
let byte_pos = (bits / 8) as usize;
let bit_pos = 7 - (bits % 8) as u8;
if sc + 4 + byte_pos < data.len() {
let non_intra = (data[sc + 4 + byte_pos] >> bit_pos) & 1 != 0;
bits += 1;
if non_intra {
bits += 64 * 8;
}
}
let total_bytes = 4 + bits.div_ceil(8) as usize;
(sc + total_bytes).min(data.len())
}
}
};
let mut seq_data = data[hdr_start..hdr_end].to_vec();
// Check if sequence extension follows immediately.
if hdr_end + 3 < data.len() && data[hdr_end + 3] == SEQ_EXT_CODE {
let ext_end = find_start_code(data, hdr_end + 4).unwrap_or(data.len());
seq_data.extend_from_slice(&data[hdr_end..ext_end]);
}
self.seq_header = Some(seq_data);
// Sequence header implies I-frame follows.
self.seq_header = Some(data[hdr_start..hdr_end].to_vec());
keyframe = true;
pos = sc + 4;
pos = if next_sc.is_some() { hdr_end } else { sc + 4 };
}
SEQ_EXT_CODE if self.seq_header.is_some() && !self.has_extension => {
// Sequence extension appears after seq header (may be in next PES).
// Append it to the stored seq_header.
let ext_end = find_start_code(data, sc + 4).unwrap_or(data.len());
if let Some(ref mut hdr) = self.seq_header {
hdr.extend_from_slice(&data[sc..ext_end]);
}
self.has_extension = true;
pos = ext_end;
}
PICTURE_CODE => {
// Picture header: bytes after start code contain temporal_reference
+195 -40
View File
@@ -1,14 +1,25 @@
//! Dolby TrueHD / Atmos elementary stream parser.
//!
//! TrueHD major sync: 0xF8726FBA at a 4-byte aligned position.
//! Access units consist of a major sync followed by minor syncs.
//! An embedded AC3 core is in substream 0 for backward compatibility.
//! All access units are keyframes.
//! Each PES packet = one access unit.
//! BD-TS TrueHD PES packets contain interleaved AC-3 + TrueHD access units.
//! Access units span PES boundaries — must buffer and reassemble.
//!
//! TrueHD access unit header (4 bytes):
//! [0..1] upper 4 bits = parity, lower 12 bits = length in 2-byte words
//! [2..3] timing value
//! [4..] substream data (major sync 0xF8726FBA may appear at offset 4)
//!
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
//! We skip AC-3 frames and only emit TrueHD access units.
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
pub struct TrueHdParser;
/// Duration of one TrueHD access unit in nanoseconds (1/1200 second).
const AU_DURATION_NS: i64 = 833_333;
pub struct TrueHdParser {
buf: Vec<u8>,
next_pts_ns: i64,
}
impl Default for TrueHdParser {
fn default() -> Self {
@@ -18,7 +29,58 @@ impl Default for TrueHdParser {
impl TrueHdParser {
pub fn new() -> Self {
Self
Self {
buf: Vec::with_capacity(32768),
next_pts_ns: 0,
}
}
/// Skip an AC-3 frame starting at the current buffer position.
/// Returns number of bytes consumed, or 0 if not enough data.
fn skip_ac3_frame(&self) -> usize {
if self.buf.len() < 6 {
return 0;
}
// AC-3 frame size from frmsizcod + fscod
// Byte 4: [fscod:2][frmsizecod:6]
let fscod = (self.buf[4] >> 6) & 0x03;
let frmsizecod = (self.buf[4] & 0x3F) as usize;
// Frame size in 16-bit words per fscod (simplified table for common rates)
let frame_words = match fscod {
0 => {
// 48 kHz
static SIZES: [usize; 38] = [
64, 64, 80, 80, 96, 96, 112, 112, 128, 128, 160, 160, 192, 192, 224, 224, 256,
256, 320, 320, 384, 384, 448, 448, 512, 512, 640, 640, 768, 768, 896, 896,
1024, 1024, 1152, 1152, 1280, 1280,
];
SIZES.get(frmsizecod).copied().unwrap_or(0)
}
1 => {
// 44.1 kHz
static SIZES: [usize; 38] = [
69, 70, 87, 88, 104, 105, 121, 122, 139, 140, 174, 175, 208, 209, 243, 244,
278, 279, 348, 349, 417, 418, 487, 488, 557, 558, 696, 697, 835, 836, 975, 976,
1114, 1115, 1253, 1254, 1393, 1394,
];
SIZES.get(frmsizecod).copied().unwrap_or(0)
}
2 => {
// 32 kHz
static SIZES: [usize; 38] = [
96, 96, 120, 120, 144, 144, 168, 168, 192, 192, 240, 240, 288, 288, 336, 336,
384, 384, 480, 480, 576, 576, 672, 672, 768, 768, 960, 960, 1152, 1152, 1344,
1344, 1536, 1536, 1728, 1728, 1920, 1920,
];
SIZES.get(frmsizecod).copied().unwrap_or(0)
}
_ => 0,
};
let frame_bytes = frame_words * 2;
if frame_bytes == 0 || self.buf.len() < frame_bytes {
return 0;
}
frame_bytes
}
}
@@ -27,12 +89,62 @@ impl CodecParser for TrueHdParser {
if pes.data.is_empty() {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
vec![Frame {
pts_ns,
keyframe: true,
data: pes.data.clone(),
}]
if let Some(pts) = pes.pts {
self.next_pts_ns = pts_to_ns(pts);
}
self.buf.extend_from_slice(&pes.data);
let mut frames = Vec::new();
loop {
if self.buf.len() < 4 {
break;
}
// AC-3 frame (interleaved): starts with sync word 0x0B77
if self.buf[0] == 0x0B && self.buf[1] == 0x77 {
let skip = self.skip_ac3_frame();
if skip == 0 {
break; // incomplete AC-3 frame, wait for more data
}
self.buf.drain(..skip);
continue;
}
// TrueHD access unit: lower 12 bits of first 2 bytes = length in words
let unit_words = (((self.buf[0] as usize) << 8) | self.buf[1] as usize) & 0xFFF;
if unit_words == 0 {
self.buf.drain(..2);
continue;
}
let unit_bytes = unit_words * 2;
if unit_bytes > 32768 {
// Likely misaligned — try to resync by scanning for AC-3 sync or
// a valid TrueHD length
self.buf.drain(..2);
continue;
}
if self.buf.len() < unit_bytes {
break; // incomplete access unit, wait for more data
}
let is_major_sync = unit_bytes >= 8
&& (u32::from_be_bytes([self.buf[4], self.buf[5], self.buf[6], self.buf[7]])
& 0xFFFF_FFFE)
== 0xF872_6FBA;
frames.push(Frame {
pts_ns: self.next_pts_ns,
keyframe: is_major_sync,
data: self.buf[..unit_bytes].to_vec(),
});
self.buf.drain(..unit_bytes);
self.next_pts_ns += AU_DURATION_NS;
}
frames
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -54,35 +166,21 @@ mod tests {
}
}
#[test]
fn parse_basic_frame() {
let mut parser = TrueHdParser::new();
// TrueHD major sync: F8 72 6F BA (at 4-byte aligned position) + payload
let data = vec![0xF8, 0x72, 0x6F, 0xBA, 0x01, 0x02, 0x03, 0x04];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
fn make_truehd_unit(size_bytes: usize) -> Vec<u8> {
let words = size_bytes / 2;
let mut data = vec![0u8; size_bytes];
data[0] = ((words >> 8) & 0x0F) as u8;
data[1] = (words & 0xFF) as u8;
data
}
#[test]
fn all_keyframes() {
let mut parser = TrueHdParser::new();
for i in 0..3 {
let data = vec![0xF8, 0x72, 0x6F, 0xBA, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "TrueHD frame should always be keyframe");
}
}
#[test]
fn codec_private_none() {
let parser = TrueHdParser::new();
assert!(parser.codec_private().is_none());
fn make_ac3_frame() -> Vec<u8> {
// Minimal AC-3 frame: sync 0x0B77, fscod=0 (48kHz), frmsizecod=0 (64 words = 128 bytes)
let mut data = vec![0u8; 128];
data[0] = 0x0B;
data[1] = 0x77;
data[4] = 0x00; // fscod=0, frmsizecod=0
data
}
#[test]
@@ -91,4 +189,61 @@ mod tests {
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
#[test]
fn parse_single_unit() {
let mut parser = TrueHdParser::new();
let unit = make_truehd_unit(200);
let pes = make_pes(unit, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data.len(), 200);
}
#[test]
fn parse_unit_spanning_two_pes() {
let mut parser = TrueHdParser::new();
let unit = make_truehd_unit(200);
let mid = 100;
let pes1 = make_pes(unit[..mid].to_vec(), Some(90000));
assert!(parser.parse(&pes1).is_empty());
let pes2 = make_pes(unit[mid..].to_vec(), Some(93000));
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data.len(), 200);
}
#[test]
fn parse_multiple_units_incrementing_pts() {
let mut parser = TrueHdParser::new();
let mut data = make_truehd_unit(100);
data.extend_from_slice(&make_truehd_unit(120));
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].data.len(), 100);
assert_eq!(frames[1].data.len(), 120);
assert_eq!(frames[1].pts_ns - frames[0].pts_ns, AU_DURATION_NS);
}
#[test]
fn skip_interleaved_ac3() {
let mut parser = TrueHdParser::new();
let ac3 = make_ac3_frame();
let truehd = make_truehd_unit(200);
let mut data = ac3;
data.extend_from_slice(&truehd);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data.len(), 200);
}
#[test]
fn codec_private_none() {
let parser = TrueHdParser::new();
assert!(parser.codec_private().is_none());
}
}
+268 -149
View File
@@ -5,12 +5,100 @@
//!
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
use crate::disc::{
detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions,
};
use crate::disc::{Disc, DiscTitle, Extent};
use crate::event::{BatchSizeReason, Event, EventKind};
use crate::sector::SectorReader;
use std::io;
/// Ramp back up to the preferred batch size after this many sectors
/// of clean reading at the current (reduced) size. 100 MiB = 51,200 sectors.
///
/// Chosen so that an isolated transient failure doesn't lock the rip at
/// size 1: once past the bad zone, we probe up after ~100 ms of good reads.
/// And so that noisy zones with occasional successes can't trigger a
/// premature probe — we need a sustained clean run.
const PROBE_THRESHOLD_SECTORS: u32 = 100 * 1024 * 1024 / 2048;
/// Halve a batch size, keeping 3-sector alignment when >= 6
/// (3-sector alignment = one AACS unit). At sizes < 6 we descend
/// through 3 → 1 without intermediate unaligned sizes.
fn halve_batch_size(size: u16) -> u16 {
let h = (size / 2).max(1);
if h >= 6 {
h - (h % 3)
} else {
h
}
}
/// Double a batch size toward a preferred max, keeping 3-sector alignment
/// when the result is >= 6.
fn double_batch_size(size: u16, preferred: u16) -> u16 {
let d = size.saturating_mul(2).min(preferred);
if d >= 6 {
d - (d % 3)
} else {
d
}
}
/// Adaptive batch sizer. Shrinks on read failure, grows after a sustained
/// clean streak. Amortizes the cost of entering a bad zone — descent happens
/// once, not once per bad sector.
#[derive(Debug)]
struct AdaptiveBatch {
preferred: u16,
current: u16,
streak_sectors: u32,
}
impl AdaptiveBatch {
fn new(preferred: u16) -> Self {
Self {
preferred,
current: preferred,
streak_sectors: 0,
}
}
fn current(&self) -> u16 {
self.current
}
/// Record a successful read of `sectors`. Returns an event if the
/// sizer probed up to a larger batch size.
fn on_success(&mut self, sectors: u16) -> Option<EventKind> {
self.streak_sectors = self.streak_sectors.saturating_add(sectors as u32);
if self.current < self.preferred && self.streak_sectors >= PROBE_THRESHOLD_SECTORS {
let new_size = double_batch_size(self.current, self.preferred);
if new_size != self.current {
self.current = new_size;
self.streak_sectors = 0;
return Some(EventKind::BatchSizeChanged {
new_size,
reason: BatchSizeReason::Probed,
});
}
}
None
}
/// Record a read failure. Returns an event if the sizer shrank.
/// Does nothing at size 1 (caller handles skip/error).
fn on_failure(&mut self) -> Option<EventKind> {
self.streak_sectors = 0;
if self.current <= 1 {
return None;
}
let new_size = halve_batch_size(self.current);
self.current = new_size;
Some(EventKind::BatchSizeChanged {
new_size,
reason: BatchSizeReason::Shrunk,
})
}
}
/// Disc stream. Reads sectors from any source → PES frames.
///
/// Sources: physical drive, ISO file, or any SectorReader.
@@ -32,9 +120,12 @@ pub struct DiscStream {
read_buf: Vec<u8>,
buf_valid: usize,
// Batch size for reads
batch_sectors: u16,
// Adaptive batch sizer — preferred comes from the caller
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
adaptive: AdaptiveBatch,
pub errors: u64,
pub skip_errors: bool,
event_fn: Option<Box<dyn Fn(Event) + Send>>,
eof: bool,
// PES output
@@ -46,100 +137,17 @@ pub struct DiscStream {
}
impl DiscStream {
/// Open from a physical drive. Caller must have already called
/// drive.wait_ready(), drive.init(), drive.probe_disc().
/// Drive is moved into the stream — caller manages lock/unlock before/after.
pub fn open_drive(
drive: crate::drive::Drive,
keydb_path: Option<&str>,
title_index: usize,
) -> crate::error::Result<(Self, Disc)> {
let scan_opts = match keydb_path {
Some(kp) => ScanOptions::with_keydb(kp),
None => ScanOptions::default(),
};
let mut drive = drive;
let disc = Disc::scan(&mut drive, &scan_opts)?;
if title_index >= disc.titles.len() {
return Err(crate::error::Error::DiscTitleRange {
index: title_index,
count: disc.titles.len(),
});
}
let title = disc.titles[title_index].clone();
let keys = disc.decrypt_keys();
let max_batch = detect_max_batch_sectors(drive.device_path());
let content_format = disc.content_format;
let mut stream = Self::from_reader(Box::new(drive), title, keys, max_batch);
// Set demuxer based on content format
match content_format {
crate::disc::ContentFormat::MpegPs => {
stream.ps_demuxer = Some(super::ps::PsDemuxer::new());
}
crate::disc::ContentFormat::BdTs => {
let pids: Vec<u16> = stream.pid_to_track.iter().map(|(pid, _)| *pid).collect();
if !pids.is_empty() {
stream.ts_demuxer = Some(super::ts::TsDemuxer::new(&pids));
}
}
}
Ok((stream, disc))
}
/// Open from an ISO file.
pub fn open_iso(
path: &str,
title_index: Option<usize>,
opts: &ScanOptions,
) -> io::Result<Self> {
let mut reader = super::iso::IsoSectorReader::open(path)?;
let capacity = reader.capacity();
let disc = Disc::scan_image(&mut reader, capacity, opts)
.map_err(|e| -> io::Error { e.into() })?;
if disc.titles.is_empty() {
return Err(crate::error::Error::NoStreams.into());
}
let idx = title_index.unwrap_or(0);
if idx >= disc.titles.len() {
return Err(crate::error::Error::DiscTitleRange {
index: idx,
count: disc.titles.len(),
}.into());
}
let title = disc.titles[idx].clone();
let keys = disc.decrypt_keys();
let batch: u16 = 64;
let mut stream = Self::from_reader(Box::new(reader), title, keys, batch);
// Set demuxer based on content format
match disc.content_format {
crate::disc::ContentFormat::MpegPs => {
stream.ps_demuxer = Some(super::ps::PsDemuxer::new());
}
crate::disc::ContentFormat::BdTs => {
let pids: Vec<u16> = stream.pid_to_track.iter().map(|(pid, _)| *pid).collect();
if !pids.is_empty() {
stream.ts_demuxer = Some(super::ts::TsDemuxer::new(&pids));
}
}
}
stream.disc = Some(disc);
Ok(stream)
}
/// Create from any SectorReader + title + keys.
pub fn from_reader(
/// Create a disc stream from any sector reader.
///
/// Works with physical drives and ISO files — both implement SectorReader.
/// The caller opens the source, scans for titles/keys, and passes them in.
/// The stream handles demuxing, decryption, and codec parsing internally.
pub fn new(
reader: Box<dyn SectorReader>,
title: DiscTitle,
decrypt_keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
content_format: crate::disc::ContentFormat,
) -> Self {
let extents = title.extents.clone();
@@ -154,7 +162,21 @@ impl DiscStream {
};
pids.push(pid);
pid_to_track.push((pid, idx));
parsers.push((pid, super::codec::parser_for_codec(codec)));
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
}
let mut ts_demuxer = None;
let mut ps_demuxer = None;
match content_format {
crate::disc::ContentFormat::MpegPs => {
ps_demuxer = Some(super::ps::PsDemuxer::new());
}
crate::disc::ContentFormat::BdTs => {
let ts_pids: Vec<u16> = pids.clone();
if !ts_pids.is_empty() {
ts_demuxer = Some(super::ts::TsDemuxer::new(&ts_pids));
}
}
}
Self {
@@ -167,19 +189,30 @@ impl DiscStream {
current_offset: 0,
read_buf: Vec::with_capacity(batch_sectors as usize * 2048),
buf_valid: 0,
batch_sectors,
adaptive: AdaptiveBatch::new(batch_sectors),
errors: 0,
skip_errors: false,
event_fn: None,
eof: false,
// Demuxer set by caller — open_drive() checks content_format,
// open_iso() always uses TS (Blu-ray ISO).
ts_demuxer: None,
ps_demuxer: None,
ts_demuxer,
ps_demuxer,
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
}
}
/// Set event handler for sector-level events (binary search, skip, recover).
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
self.event_fn = Some(Box::new(f));
}
fn emit(&self, kind: EventKind) {
if let Some(ref f) = self.event_fn {
f(Event { kind });
}
}
/// Skip decryption — return raw encrypted bytes.
pub fn set_raw(&mut self) {
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
@@ -190,38 +223,75 @@ impl DiscStream {
self.disc.as_ref()
}
fn fill_extents(&mut self) -> bool {
fn fill_extents(&mut self) -> io::Result<bool> {
if self.current_extent >= self.extents.len() {
return false;
return Ok(false);
}
let ext_start = self.extents[self.current_extent].start_lba;
let ext_sectors = self.extents[self.current_extent].sector_count;
let remaining = ext_sectors.saturating_sub(self.current_offset);
let sectors = remaining.min(self.batch_sectors as u32) as u16;
let sectors = sectors - (sectors % 3);
if sectors == 0 {
if remaining == 0 {
self.current_extent += 1;
self.current_offset = 0;
return self.fill_extents();
}
let lba = ext_start + self.current_offset;
let bytes = sectors as usize * 2048;
self.read_buf.resize(bytes, 0);
match self.reader.read_sectors(lba, sectors, &mut self.read_buf[..bytes]) {
Ok(_) => {
// Adaptive sizer: start at current (preferred until a failure), shrink
// on failure, advance on success. One 5s read attempt per try — no
// retry loops, no sleeps. On size-1 failure, skip or error.
loop {
let mut sectors = remaining.min(self.adaptive.current() as u32) as u16;
// Align to 3-sector AACS units when possible. Partial units at
// extent boundaries are safely handled by decrypt_sectors().
if sectors >= 3 {
sectors -= sectors % 3;
}
let bytes = sectors as usize * 2048;
self.read_buf.resize(bytes, 0);
let ok = self
.reader
.read_sectors(lba, sectors, &mut self.read_buf[..bytes], false)
.is_ok();
if ok {
if let Some(ev) = self.adaptive.on_success(sectors) {
self.emit(ev);
}
self.buf_valid = bytes;
self.current_offset += sectors as u32;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
break;
}
if sectors == 1 {
// Bottomed out. Skip this sector or bail.
if self.skip_errors {
self.read_buf.resize(2048, 0);
self.read_buf[..2048].fill(0);
self.buf_valid = 2048;
self.errors += 1;
self.emit(EventKind::SectorSkipped { sector: lba as u64 });
self.current_offset += 1;
break;
} else {
return Err(crate::error::Error::DiscRead { sector: lba as u64 }.into());
}
true
}
// Shrink and retry at the same LBA with a smaller batch.
if let Some(ev) = self.adaptive.on_failure() {
self.emit(ev);
}
Err(_) => false,
}
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
Ok(true)
}
}
@@ -236,16 +306,20 @@ impl crate::pes::Stream for DiscStream {
}
loop {
if !self.fill_extents() {
if !self.fill_extents()? {
self.eof = true;
// Flush demuxer — last PES packet may still be in the assembler
if let Some(ref mut demuxer) = self.ts_demuxer {
for pes in &demuxer.flush() {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, track)) =
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
{
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame)
crate::pes::PesFrame::from_codec_frame(*track, frame),
);
}
}
@@ -258,17 +332,34 @@ impl crate::pes::Stream for DiscStream {
let track = match ps.stream_id {
0xE0..=0xEF => 0,
0xC0..=0xDF => 1,
0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1),
0xBD => ps
.sub_stream_id
.map(|s| (s & 0x1F) as usize + 1)
.unwrap_or(1),
_ => continue,
};
if track < self.title.streams.len() {
let pts_ns = ps.pts.map(|p| (p as i64) * 1_000_000_000 / 90_000).unwrap_or(0);
self.pending_frames.push_back(crate::pes::PesFrame {
track,
pts: pts_ns,
keyframe: true,
data: ps.data.clone(),
});
if track >= self.title.streams.len() {
continue;
}
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(p, _)| *p)
.unwrap_or(0);
let pes = super::ts::PesPacket {
pid,
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
{
for frame in parser.parse(&pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(track, frame),
);
}
}
}
}
@@ -276,22 +367,24 @@ impl crate::pes::Stream for DiscStream {
}
let bytes = self.buf_valid;
if let Err(e) = crate::decrypt::decrypt_sectors(
&mut self.read_buf[..bytes],
&self.decrypt_keys,
0,
) {
if let Err(e) =
crate::decrypt::decrypt_sectors(&mut self.read_buf[..bytes], &self.decrypt_keys, 0)
{
return Err(e.into());
}
if let Some(ref mut demuxer) = self.ts_demuxer {
let packets = demuxer.feed(&self.read_buf[..bytes]);
for pes in &packets {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, track)) =
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
{
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame)
crate::pes::PesFrame::from_codec_frame(*track, frame),
);
}
}
@@ -303,17 +396,36 @@ impl crate::pes::Stream for DiscStream {
let track = match ps.stream_id {
0xE0..=0xEF => 0,
0xC0..=0xDF => 1,
0xBD => ps.sub_stream_id.map(|s| (s & 0x1F) as usize + 1).unwrap_or(1),
0xBD => ps
.sub_stream_id
.map(|s| (s & 0x1F) as usize + 1)
.unwrap_or(1),
_ => continue,
};
if track < self.title.streams.len() {
let pts_ns = ps.pts.map(|p| (p as i64) * 1_000_000_000 / 90_000).unwrap_or(0);
self.pending_frames.push_back(crate::pes::PesFrame {
track,
pts: pts_ns,
keyframe: true,
data: ps.data.clone(),
});
if track >= self.title.streams.len() {
continue;
}
// Convert PsPacket to PesPacket for codec parser (same as BD-TS path)
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(p, _)| *p)
.unwrap_or(0);
let pes = super::ts::PesPacket {
pid,
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
for frame in parser.parse(&pes) {
self.pending_frames
.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
}
}
@@ -330,15 +442,22 @@ impl crate::pes::Stream for DiscStream {
Err(crate::error::Error::StreamReadOnly.into())
}
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn info(&self) -> &DiscTitle { &self.title }
fn info(&self) -> &DiscTitle {
&self.title
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self.pid_to_track.iter()
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers.iter()
self.parsers
.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
+11 -4
View File
@@ -26,7 +26,8 @@ impl IsoSectorReader {
if sectors > u32::MAX as u64 {
return Err(crate::error::Error::IsoTooLarge {
path: path.to_string(),
}.into());
}
.into());
}
let capacity = sectors as u32;
Ok(Self { file, capacity })
@@ -38,7 +39,13 @@ impl IsoSectorReader {
}
impl SectorReader for IsoSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * SECTOR_SIZE as usize;
self.file
.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE))
@@ -70,11 +77,11 @@ mod tests {
assert_eq!(reader.capacity(), 4);
let mut buf = [0u8; 2048];
reader.read_sectors(0, 1, &mut buf).unwrap();
reader.read_sectors(0, 1, &mut buf, true).unwrap();
assert_eq!(buf[0], 1);
assert_eq!(buf[2047], 100);
reader.read_sectors(2, 1, &mut buf).unwrap();
reader.read_sectors(2, 1, &mut buf, true).unwrap();
assert_eq!(buf[0], 3);
assert_eq!(buf[2047], 102);
+52 -21
View File
@@ -7,7 +7,11 @@ use super::{meta, ts};
use crate::disc::{DiscTitle, Stream as DiscStream};
use std::io::{self, Read, Write};
type PesSetup = (Vec<u16>, Vec<(u16, Box<dyn super::codec::CodecParser>)>, Vec<(u16, usize)>);
type PesSetup = (
Vec<u16>,
Vec<(u16, Box<dyn super::codec::CodecParser>)>,
Vec<(u16, usize)>,
);
/// Size of initial scan buffer for PMT/stream detection.
const SCAN_SIZE: usize = 1024 * 1024;
@@ -59,11 +63,15 @@ impl M2tsStream {
let m = meta::M2tsMeta::from_title(title);
meta::write_header(&mut writer, &m)?;
}
let pids: Vec<u16> = title.streams.iter().map(|s| match s {
DiscStream::Video(v) => v.pid,
DiscStream::Audio(a) => a.pid,
DiscStream::Subtitle(s) => s.pid,
}).collect();
let pids: Vec<u16> = title
.streams
.iter()
.map(|s| match s {
DiscStream::Video(v) => v.pid,
DiscStream::Audio(a) => a.pid,
DiscStream::Subtitle(s) => s.pid,
})
.collect();
let boxed: Box<dyn Write> = Box::new(writer);
let mut muxer = super::tsmux::TsMuxer::new(boxed, &pids);
for (i, cp) in title.codec_privates.iter().enumerate() {
@@ -95,7 +103,7 @@ impl M2tsStream {
};
pids.push(pid);
pid_to_track.push((pid, i));
parsers.push((pid, super::codec::parser_for_codec(codec)));
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
}
(pids, parsers, pid_to_track)
}
@@ -117,11 +125,16 @@ impl M2tsStream {
let (pids, parsers, pid_to_track) = Self::setup_pes(&title.streams);
// Chain: remaining head bytes + rest of reader
let remaining_head = &head[header_end..];
let chain: Box<dyn Read> = Box::new(io::Cursor::new(remaining_head.to_vec()).chain(reader));
let chain: Box<dyn Read> =
Box::new(io::Cursor::new(remaining_head.to_vec()).chain(reader));
return Ok(Self {
disc_title: title.clone(),
mode: Mode::Read { reader: chain },
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
demuxer: if pids.is_empty() {
None
} else {
Some(ts::TsDemuxer::new(&pids))
},
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
@@ -146,7 +159,11 @@ impl M2tsStream {
..DiscTitle::empty()
},
mode: Mode::Read { reader: chain },
demuxer: if pids.is_empty() { None } else { Some(ts::TsDemuxer::new(&pids)) },
demuxer: if pids.is_empty() {
None
} else {
Some(ts::TsDemuxer::new(&pids))
},
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
@@ -161,7 +178,9 @@ impl crate::pes::Stream for M2tsStream {
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
if self.pes_eof { return Ok(None); }
if self.pes_eof {
return Ok(None);
}
loop {
let reader = match &mut self.mode {
@@ -175,11 +194,15 @@ impl crate::pes::Stream for M2tsStream {
// Flush demuxer — last PES packet may still be in the assembler
if let Some(ref mut demuxer) = self.demuxer {
for pes in &demuxer.flush() {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, track)) =
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
{
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame)
crate::pes::PesFrame::from_codec_frame(*track, frame),
);
}
}
@@ -192,11 +215,15 @@ impl crate::pes::Stream for M2tsStream {
if let Some(ref mut demuxer) = self.demuxer {
let packets = demuxer.feed(&buf[..n]);
for pes in &packets {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, track)) =
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
{
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame)
crate::pes::PesFrame::from_codec_frame(*track, frame),
);
}
}
@@ -224,7 +251,9 @@ impl crate::pes::Stream for M2tsStream {
}
}
fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title }
fn info(&self) -> &crate::disc::DiscTitle {
&self.disc_title
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
// First check stored codec_privates from FMKV header
@@ -232,10 +261,13 @@ impl crate::pes::Stream for M2tsStream {
return Some(cp.clone());
}
// Fall back to parser-extracted codec_private
let pid = self.pid_to_track.iter()
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers.iter()
self.parsers
.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
@@ -251,4 +283,3 @@ impl crate::pes::Stream for M2tsStream {
true
}
}
+31 -14
View File
@@ -93,7 +93,9 @@ impl M2tsMeta {
hdr: v.hdr.id().into(),
label: v.label.clone(),
secondary: v.secondary,
codec_private: title.codec_privates.get(i)
codec_private: title
.codec_privates
.get(i)
.and_then(|cp| cp.as_ref())
.map(|cp| base64::engine::general_purpose::STANDARD.encode(cp)),
},
@@ -141,8 +143,12 @@ impl M2tsMeta {
} => Stream::Video(VideoStream {
pid: *pid,
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
resolution: resolution.parse().unwrap_or(crate::disc::Resolution::Unknown),
frame_rate: frame_rate.parse().unwrap_or(crate::disc::FrameRate::Unknown),
resolution: resolution
.parse()
.unwrap_or(crate::disc::Resolution::Unknown),
frame_rate: frame_rate
.parse()
.unwrap_or(crate::disc::FrameRate::Unknown),
hdr: hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr),
color_space: ColorSpace::Bt709,
secondary: *secondary,
@@ -159,9 +165,13 @@ impl M2tsMeta {
} => Stream::Audio(AudioStream {
pid: *pid,
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
channels: channels.parse().unwrap_or(crate::disc::AudioChannels::Unknown),
channels: channels
.parse()
.unwrap_or(crate::disc::AudioChannels::Unknown),
language: language.clone(),
sample_rate: sample_rate.parse().unwrap_or(crate::disc::SampleRate::Unknown),
sample_rate: sample_rate
.parse()
.unwrap_or(crate::disc::SampleRate::Unknown),
secondary: *secondary,
label: label.clone(),
}),
@@ -197,16 +207,23 @@ impl M2tsMeta {
/// Extract codec_private data per stream (from FMKV header).
/// Returns a Vec matching stream order — None for streams without codec_private.
pub fn codec_privates(&self) -> Vec<Option<Vec<u8>>> {
self.streams.iter().map(|s| {
if let MetaStream::Video { codec_private: Some(ref b64), .. } = s {
{
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(b64).ok()
self.streams
.iter()
.map(|s| {
if let MetaStream::Video {
codec_private: Some(ref b64),
..
} = s
{
{
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(b64).ok()
}
} else {
None
}
} else {
None
}
}).collect()
})
.collect()
}
}
+118 -27
View File
@@ -5,7 +5,9 @@
//! cues and seek head are finalized at the end.
use super::ebml;
use crate::disc::{AudioStream, Chapter, Codec, SubtitleStream, VideoStream};
use crate::disc::{
AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
};
use std::io::{self, Seek, SeekFrom, Write};
/// MKV track definition (built from disc stream metadata).
@@ -20,6 +22,14 @@ pub struct MkvTrack {
// Video-specific
pub pixel_width: u32,
pub pixel_height: u32,
pub default_duration_ns: u64, // nanoseconds per frame (0 = unknown)
pub display_width: u32, // display aspect ratio width (0 = same as pixel)
pub display_height: u32, // display aspect ratio height (0 = same as pixel)
// HDR colour metadata
pub colour_matrix: u8, // MatrixCoefficients (9=bt2020nc)
pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ)
pub colour_primaries: u8, // Primaries (9=bt2020)
pub colour_range: u8, // Range (1=tv/limited)
// Audio-specific
pub sample_rate: f64,
pub channels: u8,
@@ -36,16 +46,40 @@ impl MkvTrack {
_ => "V_MPEG2",
};
let (w, h) = v.resolution.pixels();
let (num, den) = v.frame_rate.as_fraction();
let default_duration_ns = if num > 0 {
(1_000_000_000u64 * den as u64) / num as u64
} else {
0
};
let (matrix, transfer, primaries, range) = match v.color_space {
ColorSpace::Bt2020 => (9, 16, 9, 1), // bt2020nc, PQ, bt2020, limited
ColorSpace::Bt709 => (1, 1, 1, 1), // bt709
ColorSpace::Unknown => (0, 0, 0, 0),
};
// Override transfer for non-PQ HDR
let transfer = match v.hdr {
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => 16, // PQ
HdrFormat::Hlg => 18,
_ => transfer,
};
Self {
track_type: ebml::TRACK_TYPE_VIDEO,
codec_id,
language: "und".into(),
name: v.label.clone(),
codec_private: None, // filled later by parser
codec_private: None,
is_default: !v.secondary,
is_forced: false,
pixel_width: w,
pixel_height: h,
default_duration_ns,
display_width: w,
display_height: h,
colour_matrix: matrix,
colour_transfer: transfer,
colour_primaries: primaries,
colour_range: range,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -63,16 +97,26 @@ impl MkvTrack {
};
let sr = a.sample_rate.hz();
let ch = a.channels.count();
let name = a.label.clone();
Self {
track_type: ebml::TRACK_TYPE_AUDIO,
codec_id,
language: a.language.clone(),
name: a.label.clone(),
name,
codec_private: None,
is_default: !a.secondary,
is_forced: false,
pixel_width: 0,
pixel_height: 0,
default_duration_ns: 0,
display_width: 0,
display_height: 0,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
sample_rate: sr,
channels: ch,
bit_depth: 0,
@@ -94,6 +138,13 @@ impl MkvTrack {
is_forced: s.forced,
pixel_width: 0,
pixel_height: 0,
default_duration_ns: 0,
display_width: 0,
display_height: 0,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -116,6 +167,7 @@ pub struct MkvMuxer<W: Write + Seek> {
cluster_pos: u64,
cluster_size_pos: u64,
cluster_ts_ms: i64,
base_pts_ms: Option<i64>,
cues: Vec<CuePoint>,
frame_count: u64,
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
@@ -130,16 +182,6 @@ const CLUSTER_DURATION_MS: i64 = 5000;
impl<W: Write + Seek> MkvMuxer<W> {
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
pub fn new(
writer: W,
tracks: &[MkvTrack],
title: Option<&str>,
duration_secs: f64,
) -> io::Result<Self> {
Self::new_with_chapters(writer, tracks, title, duration_secs, &[])
}
/// Create a new MKV muxer with chapters: writes EBML header, Segment start, Info, Tracks, Chapters.
pub fn new_with_chapters(
mut writer: W,
tracks: &[MkvTrack],
title: Option<&str>,
@@ -216,11 +258,45 @@ impl<W: Write + Seek> MkvMuxer<W> {
codec_private_filled.push(true);
}
// DefaultDuration — frame duration in nanoseconds
if track.default_duration_ns > 0 {
ebml::write_uint(
&mut writer,
ebml::DEFAULT_DURATION,
track.default_duration_ns,
)?;
}
// Video-specific
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?;
ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?;
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
if track.display_width > 0 && track.display_height > 0 {
ebml::write_uint(&mut writer, ebml::DISPLAY_WIDTH, track.display_width as u64)?;
ebml::write_uint(
&mut writer,
ebml::DISPLAY_HEIGHT,
track.display_height as u64,
)?;
}
// Colour metadata (HDR)
if track.colour_matrix > 0 || track.colour_transfer > 0 {
let col_pos = ebml::start_master(&mut writer, ebml::COLOUR)?;
ebml::write_uint(
&mut writer,
ebml::MATRIX_COEFFICIENTS,
track.colour_matrix as u64,
)?;
ebml::write_uint(
&mut writer,
ebml::TRANSFER_CHARACTERISTICS,
track.colour_transfer as u64,
)?;
ebml::write_uint(&mut writer, ebml::PRIMARIES, track.colour_primaries as u64)?;
ebml::write_uint(&mut writer, ebml::RANGE, track.colour_range as u64)?;
ebml::end_master(&mut writer, col_pos)?;
}
ebml::end_master(&mut writer, vid_pos)?;
}
@@ -265,6 +341,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
cluster_pos: 0,
cluster_size_pos: 0,
cluster_ts_ms: 0,
base_pts_ms: None,
cues: Vec::new(),
frame_count: 0,
codec_private_slots,
@@ -280,7 +357,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
keyframe: bool,
data: &[u8],
) -> io::Result<()> {
let pts_ms = pts_ns / 1_000_000;
let raw_ms = pts_ns / 1_000_000;
let base = *self.base_pts_ms.get_or_insert(raw_ms);
let pts_ms = raw_ms - base;
// Start new cluster if needed
if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS {
@@ -450,6 +529,13 @@ mod tests {
is_forced: false,
pixel_width: 1920,
pixel_height: 1080,
default_duration_ns: 41708333,
display_width: 1920,
display_height: 1080,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -467,6 +553,13 @@ mod tests {
is_forced: false,
pixel_width: 0,
pixel_height: 0,
default_duration_ns: 0,
display_width: 0,
display_height: 0,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
sample_rate: 48000.0,
channels: 6,
bit_depth: 0,
@@ -477,7 +570,7 @@ mod tests {
fn mkv_writes_ebml_header() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// EBML header element ID: 0x1A45DFA3
assert!(data.len() >= 4);
@@ -488,7 +581,7 @@ mod tests {
fn mkv_writes_segment() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, None, 0.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// Segment element ID: 0x18538067
assert!(
@@ -501,7 +594,7 @@ mod tests {
fn mkv_write_frame_creates_cluster() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF])
.unwrap();
@@ -540,7 +633,7 @@ mod tests {
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0).unwrap();
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
muxer.write_frame(0, 0, true, &[0x01, 0x02, 0x03]).unwrap();
muxer.finish().unwrap();
@@ -555,7 +648,7 @@ mod tests {
fn mkv_multiple_tracks() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track(), make_audio_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0).unwrap();
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
// Write frames to both tracks
muxer.write_frame(0, 0, true, &[0x00, 0x00, 0x01]).unwrap();
muxer.write_frame(1, 0, false, &[0x0B, 0x77, 0x00]).unwrap();
@@ -574,7 +667,7 @@ mod tests {
fn mkv_keyframe_flag() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0).unwrap();
let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0, &[]).unwrap();
// Record position before first frame
let pos_before_kf = muxer.writer.position();
@@ -648,9 +741,7 @@ mod tests {
name: "Chapter 3".into(),
},
];
let muxer =
MkvMuxer::new_with_chapters(buf, &tracks, Some("Chapter Test"), 900.0, &chapters)
.unwrap();
let muxer = MkvMuxer::new(buf, &tracks, Some("Chapter Test"), 900.0, &chapters).unwrap();
let data = muxer.writer.into_inner();
// Chapters element ID: 0x1043A770
@@ -674,7 +765,7 @@ mod tests {
fn mkv_no_chapters_when_empty() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, Some("No Chapters"), 60.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, Some("No Chapters"), 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::CHAPTERS).is_none(),
@@ -693,7 +784,7 @@ mod tests {
let buf = Cursor::new(Vec::new());
let tracks = [video, audio1, audio2];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagDefault ID is 0x88. When is_default is true, FlagDefault is NOT written
@@ -725,7 +816,7 @@ mod tests {
let buf = Cursor::new(Vec::new());
let tracks = [video, forced_sub];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagForced ID: 0x55AA (2-byte ID)
@@ -750,7 +841,7 @@ mod tests {
let buf = Cursor::new(Vec::new());
let tracks = [video, sub];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagForced should NOT be written for non-forced tracks
+50 -18
View File
@@ -40,24 +40,31 @@ pub struct MkvStream {
impl MkvStream {
/// Create for writing PES frames → MKV container.
/// Codec privates come from title.codec_privates (populated by input stream).
pub fn create(
writer: Box<dyn WriteSeek>,
title: &DiscTitle,
) -> io::Result<Self> {
pub fn create(writer: Box<dyn WriteSeek>, title: &DiscTitle) -> io::Result<Self> {
let mut tracks = Vec::new();
let mut has_default_video = false;
let mut has_default_audio = false;
for (idx, s) in title.streams.iter().enumerate() {
let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s),
};
// Only first video and first audio are default
if track.is_default {
match track.track_type {
1 if !has_default_video => has_default_video = true,
2 if !has_default_audio => has_default_audio = true,
_ => track.is_default = false,
}
}
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
track.codec_private = Some(cp.clone());
}
tracks.push(track);
}
let muxer = MkvMuxer::new_with_chapters(
let muxer = MkvMuxer::new(
writer,
&tracks,
Some(&title.playlist),
@@ -106,10 +113,14 @@ impl crate::pes::Stream for MkvStream {
}
ebml::SIMPLE_BLOCK => {
let block = ebml::read_binary_val(&mut rs.reader, size as usize)?;
if block.len() < 4 { continue; }
if block.len() < 4 {
continue;
}
let (track, vl) = block_vint(&block);
if vl + 3 > block.len() { continue; }
if vl + 3 > block.len() {
continue;
}
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
let keyframe = block[vl + 2] & 0x80 != 0;
@@ -139,7 +150,9 @@ impl crate::pes::Stream for MkvStream {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.mode {
Mode::Write { muxer: Some(ref mut m) } => m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data),
Mode::Write {
muxer: Some(ref mut m),
} => m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data),
Mode::Write { muxer: None } => Ok(()),
Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()),
}
@@ -154,7 +167,9 @@ impl crate::pes::Stream for MkvStream {
Ok(())
}
fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title }
fn info(&self) -> &crate::disc::DiscTitle {
&self.disc_title
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let track_num = (track + 1) as u16; // MKV tracks are 1-based
@@ -176,9 +191,7 @@ impl crate::pes::Stream for MkvStream {
// ── MKV header parsing (read side) ────────────────────────────
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
fn parse_mkv_header(
r: &mut impl Read,
) -> MkvHeaderResult {
fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
let mut title = String::new();
let mut duration_ms = 0.0f64;
let mut ts_scale: u64 = 1_000_000;
@@ -220,7 +233,9 @@ fn parse_mkv_header(
ebml::TIMESTAMP_SCALE => ts_scale = ebml::read_uint_val(r, cs as usize)?,
ebml::DURATION => duration_ms = ebml::read_float_val(r, cs as usize)?,
ebml::TITLE => title = ebml::read_string_val(r, cs as usize)?,
_ => { skip_bytes(r, cs)?; }
_ => {
skip_bytes(r, cs)?;
}
}
}
got_info = true;
@@ -303,11 +318,15 @@ fn parse_track(
match aid {
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
ebml::CHANNELS => ch = ebml::read_uint_val(r, as_ as usize)? as u8,
_ => { skip_bytes(r, as_)?; }
_ => {
skip_bytes(r, as_)?;
}
}
}
}
_ => { skip_bytes(r, cs)?; }
_ => {
skip_bytes(r, cs)?;
}
}
}
@@ -334,7 +353,11 @@ fn parse_track(
};
// Map MKV track numbers to BD-TS PIDs
let ts_pid = if tnum == 1 { 0x1011 } else { 0x1100 + (tnum - 2) };
let ts_pid = if tnum == 1 {
0x1011
} else {
0x1100 + (tnum - 2)
};
let stream = match ttype {
1 => {
@@ -382,10 +405,19 @@ fn block_vint(d: &[u8]) -> (u64, usize) {
return ((((d[0] & 0x3F) as u64) << 8) | d[1] as u64, 2);
}
if d[0] & 0x20 != 0 && d.len() >= 3 {
return ((((d[0] & 0x1F) as u64) << 16) | ((d[1] as u64) << 8) | d[2] as u64, 3);
return (
(((d[0] & 0x1F) as u64) << 16) | ((d[1] as u64) << 8) | d[2] as u64,
3,
);
}
if d[0] & 0x10 != 0 && d.len() >= 4 {
return ((((d[0] & 0x0F) as u64) << 24) | ((d[1] as u64) << 16) | ((d[2] as u64) << 8) | d[3] as u64, 4);
return (
(((d[0] & 0x0F) as u64) << 24)
| ((d[1] as u64) << 16)
| ((d[2] as u64) << 8)
| d[3] as u64,
4,
);
}
(0, 1) // Unsupported 5+ byte VINT — treat as track 0
}
+3 -3
View File
@@ -19,11 +19,9 @@ pub mod codec;
pub mod disc;
pub mod ebml;
pub mod iso;
pub mod mkv;
pub mod tsmux;
pub mod tsreader;
mod m2ts;
pub mod meta;
pub mod mkv;
mod mkvstream;
pub mod network;
pub mod null;
@@ -31,6 +29,8 @@ pub mod ps;
pub mod resolve;
pub mod stdio;
pub mod ts;
pub mod tsmux;
pub mod tsreader;
pub use disc::DiscStream;
pub use iso::IsoSectorReader;
+14 -3
View File
@@ -79,7 +79,11 @@ impl crate::pes::Stream for NetworkStream {
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.mode {
Mode::Write { writer, ref mut header_written, .. } => {
Mode::Write {
writer,
ref mut header_written,
..
} => {
if !*header_written {
if !self.disc_title.streams.is_empty() {
let m = meta::M2tsMeta::from_title(&self.disc_title);
@@ -99,7 +103,9 @@ impl crate::pes::Stream for NetworkStream {
}
Ok(())
}
fn info(&self) -> &DiscTitle { &self.disc_title }
fn info(&self) -> &DiscTitle {
&self.disc_title
}
}
// NetworkStream is PES-only — no IOStream/Read/Write byte interface.
@@ -174,7 +180,12 @@ mod tests {
let dt = sample_title();
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
let frame = pes::PesFrame { track: 0, pts: 90000, keyframe: true, data: vec![0x47; 192] };
let frame = pes::PesFrame {
track: 0,
pts: 90000,
keyframe: true,
data: vec![0x47; 192],
};
pes::Stream::write(&mut writer, &frame).unwrap();
pes::Stream::finish(&mut writer).unwrap();
+12 -4
View File
@@ -17,8 +17,16 @@ impl NullStream {
}
impl crate::pes::Stream for NullStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { Ok(None) }
fn write(&mut self, _: &crate::pes::PesFrame) -> io::Result<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &DiscTitle { &self.disc_title }
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
Ok(None)
}
fn write(&mut self, _: &crate::pes::PesFrame) -> io::Result<()> {
Ok(())
}
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.disc_title
}
}
-1
View File
@@ -163,7 +163,6 @@ impl PsDemuxer {
packets
}
}
/// Check whether a start code byte is a valid PES stream ID that carries payload.
+54 -57
View File
@@ -128,12 +128,14 @@ fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
if path.as_os_str().is_empty() {
return Err(crate::error::Error::StreamUrlMissingPath {
scheme: scheme.to_string(),
}.into());
}
.into());
}
if path.file_name().is_none() {
return Err(crate::error::Error::StreamUrlInvalid {
url: format!("{scheme}://{}", path.display()),
}.into());
}
.into());
}
Ok(())
}
@@ -143,12 +145,14 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
if addr.is_empty() {
return Err(crate::error::Error::StreamUrlMissingPath {
scheme: "network".to_string(),
}.into());
}
.into());
}
if !addr.contains(':') {
return Err(crate::error::Error::StreamUrlMissingPort {
addr: addr.to_string(),
}.into());
}
.into());
}
Ok(())
}
@@ -166,26 +170,13 @@ pub struct InputOptions {
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::Disc { device } => {
// Open drive, init, scan — caller manages the drive
let mut drive = match device {
Some(ref d) => crate::drive::Drive::open(d)
.map_err(|e| -> io::Error { e.into() })?,
None => crate::drive::find_drive()
.ok_or_else(|| -> io::Error { crate::error::Error::DeviceNotFound { path: String::new() }.into() })?,
};
let _ = drive.wait_ready();
let _ = drive.init();
let _ = drive.probe_disc();
let (mut stream, _disc) = DiscStream::open_drive(
drive,
opts.keydb_path.as_deref(),
opts.title_index.unwrap_or(0),
).map_err(|e| -> io::Error { e.into() })?;
if opts.raw {
stream.set_raw();
}
Ok(Box::new(stream))
StreamUrl::Disc { .. } => {
// Disc sources should use DiscStream::new() directly.
// The caller opens the drive, inits, scans, then creates the stream.
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources",
))
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
@@ -193,7 +184,25 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
Some(p) => crate::disc::ScanOptions::with_keydb(p),
None => crate::disc::ScanOptions::default(),
};
let mut stream = DiscStream::open_iso(&path.to_string_lossy(), opts.title_index, &scan_opts)?;
let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?;
let capacity = reader.capacity();
let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts)
.map_err(|e| -> io::Error { e.into() })?;
if disc.titles.is_empty() {
return Err(crate::error::Error::NoStreams.into());
}
let idx = opts.title_index.unwrap_or(0);
if idx >= disc.titles.len() {
return Err(crate::error::Error::DiscTitleRange {
index: idx,
count: disc.titles.len(),
}
.into());
}
let title = disc.titles[idx].clone();
let keys = disc.decrypt_keys();
let format = disc.content_format;
let mut stream = DiscStream::new(Box::new(reader), title, keys, 64, format);
if opts.raw {
stream.set_raw();
}
@@ -201,15 +210,17 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::open(path)
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e)))?;
let file = std::fs::File::open(path).map_err(|e| {
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
})?;
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::open(reader)?))
}
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::open(path)
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
let file = std::fs::File::open(path).map_err(|e| {
io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e))
})?;
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::open(reader)?))
}
@@ -217,16 +228,10 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::listen(addr)?))
}
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::input()))
}
StreamUrl::Null => {
Err(crate::error::Error::StreamWriteOnly.into())
}
StreamUrl::Stdio => Ok(Box::new(StdioStream::input())),
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid {
url: raw.clone(),
}.into())
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
}
}
}
@@ -240,16 +245,18 @@ pub fn output(
match parsed {
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e)))?;
let file = std::fs::File::create(path).map_err(|e| {
io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e))
})?;
let writer: Box<dyn super::WriteSeek> =
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
Ok(Box::new(MkvStream::create(writer, title)?))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e)))?;
let file = std::fs::File::create(path).map_err(|e| {
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
})?;
let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::create(writer, title)?))
}
@@ -257,22 +264,12 @@ pub fn output(
validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::connect(addr)?.meta(title)))
}
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::output(title)))
}
StreamUrl::Null => {
Ok(Box::new(NullStream::new(title)))
}
StreamUrl::Disc { .. } => {
Err(crate::error::Error::StreamReadOnly.into())
}
StreamUrl::Iso { .. } => {
Err(crate::error::Error::StreamReadOnly.into())
}
StreamUrl::Stdio => Ok(Box::new(StdioStream::output(title))),
StreamUrl::Null => Ok(Box::new(NullStream::new(title))),
StreamUrl::Disc { .. } => Err(crate::error::Error::StreamReadOnly.into()),
StreamUrl::Iso { .. } => Err(crate::error::Error::StreamReadOnly.into()),
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid {
url: raw.clone(),
}.into())
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
}
}
}
+9 -3
View File
@@ -84,13 +84,19 @@ impl crate::pes::Stream for StdioStream {
}
}
fn finish(&mut self) -> io::Result<()> {
if let Some(w) = &mut self.writer { w.flush()?; }
if let Some(w) = &mut self.writer {
w.flush()?;
}
Ok(())
}
fn info(&self) -> &DiscTitle { &self.disc_title }
fn info(&self) -> &DiscTitle {
&self.disc_title
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
self.stored_codec_privates.get(track).and_then(|c| c.clone())
self.stored_codec_privates
.get(track)
.and_then(|c| c.clone())
}
fn headers_ready(&self) -> bool {
+14 -5
View File
@@ -99,8 +99,8 @@ impl PesAssembler {
/// BD Transport Stream demuxer.
pub struct TsDemuxer {
assemblers: Vec<PesAssembler>,
pid_index: [i16; 8192], // PID → index into assemblers, -1 = not tracked
remainder: Vec<u8>, // leftover bytes from previous feed() call
pid_index: Vec<i16>, // PID → index into assemblers, -1 = not tracked
remainder: Vec<u8>, // leftover bytes from previous feed() call
}
impl TsDemuxer {
@@ -118,7 +118,9 @@ impl TsDemuxer {
/// Create a new demuxer tracking the given PIDs.
pub fn new(pids: &[u16]) -> Self {
let mut pid_index = [-1i16; 8192];
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
let table_size = (max_pid + 1).max(8192);
let mut pid_index = vec![-1i16; table_size];
let mut assemblers = Vec::with_capacity(pids.len());
for (i, &pid) in pids.iter().enumerate() {
pid_index[pid as usize] = i as i16;
@@ -167,7 +169,11 @@ impl TsDemuxer {
let adaptation = (ts[3] >> 4) & 0x03;
// Check if we're tracking this PID
let idx = self.pid_index[pid as usize];
let idx = if (pid as usize) < self.pid_index.len() {
self.pid_index[pid as usize]
} else {
-1
};
if idx < 0 {
continue;
}
@@ -329,7 +335,10 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
let section_len = (((data[pat_start + 1] & 0x0F) as usize) << 8)
| data[pat_start + 2] as usize;
let entries_start = pat_start + 8;
if section_len < 4 { offset += BD_TS_PACKET_SIZE; continue; }
if section_len < 4 {
offset += BD_TS_PACKET_SIZE;
continue;
}
let entries_end = pat_start + 3 + section_len - 4;
let mut e = entries_start;
while e + 4 <= data.len() && e < entries_end {
+26 -14
View File
@@ -12,9 +12,10 @@ const TS_PAYLOAD: usize = 184;
pub struct TsMuxer<W: Write> {
writer: W,
pids: Vec<u16>,
continuity: Vec<u8>, // per-PID continuity counter (0-15)
continuity: Vec<u8>, // per-PID continuity counter (0-15)
codec_privates: Vec<Option<Vec<u8>>>, // per-track codec_private (for video parameter sets)
params_written: Vec<bool>, // per-track: have we written parameter sets?
params_written: Vec<bool>, // per-track: have we written parameter sets?
base_pts_ns: Option<i64>,
}
impl<W: Write> TsMuxer<W> {
@@ -26,6 +27,7 @@ impl<W: Write> TsMuxer<W> {
continuity: vec![0u8; n],
codec_privates: vec![None; n],
params_written: vec![false; n],
base_pts_ns: None,
}
}
@@ -40,15 +42,13 @@ impl<W: Write> TsMuxer<W> {
/// Write a PES frame as BD-TS packets.
/// Video frame data is expected as length-prefixed NALUs (MKV/PES format)
/// and is converted to Annex B for transport stream.
pub fn write_frame(
&mut self,
track: usize,
pts_ns: i64,
data: &[u8],
) -> io::Result<()> {
pub fn write_frame(&mut self, track: usize, pts_ns: i64, data: &[u8]) -> io::Result<()> {
if track >= self.pids.len() {
return Ok(()); // unknown track, skip
}
let base = *self.base_pts_ns.get_or_insert(pts_ns);
let pts_ns = pts_ns - base;
let pid = self.pids[track];
let is_video = (0x1011..=0x101F).contains(&pid);
@@ -125,11 +125,13 @@ impl<W: Write> TsMuxer<W> {
self.writer.write_all(&STUFF_FF[..stuff_len - 2])?;
}
}
self.writer.write_all(&pes_packet[offset..offset + payload_len])?;
self.writer
.write_all(&pes_packet[offset..offset + payload_len])?;
} else {
self.writer.write_all(&tp_extra)?;
self.writer.write_all(&ts_header)?;
self.writer.write_all(&pes_packet[offset..offset + payload_len])?;
self.writer
.write_all(&pes_packet[offset..offset + payload_len])?;
}
offset += payload_len;
@@ -202,24 +204,34 @@ fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
let mut offset = 23;
for _ in 0..num_arrays {
if offset + 3 > hvcc.len() { break; }
if offset + 3 > hvcc.len() {
break;
}
// array: 1 byte (completeness + NAL type), 2 bytes (numNalus)
let _nal_type = hvcc[offset] & 0x3F;
let num_nalus = u16::from_be_bytes([hvcc[offset + 1], hvcc[offset + 2]]) as usize;
offset += 3;
for _ in 0..num_nalus {
if offset + 2 > hvcc.len() { break; }
if offset + 2 > hvcc.len() {
break;
}
let nal_len = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
offset += 2;
if offset + nal_len > hvcc.len() { break; }
if offset + nal_len > hvcc.len() {
break;
}
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
out.extend_from_slice(&hvcc[offset..offset + nal_len]);
offset += nal_len;
}
}
if out.is_empty() { None } else { Some(out) }
if out.is_empty() {
None
} else {
Some(out)
}
}
/// Convert length-prefixed NALUs (4-byte BE length + NAL) to Annex B
+13 -6
View File
@@ -37,7 +37,7 @@ impl<R: Read> TsDemuxReader<R> {
};
pids.push(pid);
pid_to_track.push((pid, i));
parsers.push((pid, codec::parser_for_codec(c)));
parsers.push((pid, codec::parser_for_codec(c, None)));
}
Self {
@@ -69,10 +69,14 @@ impl<R: Read> TsDemuxReader<R> {
let packets = self.demuxer.feed(&self.buf[..n]);
for pes in &packets {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
{
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(pes) {
self.pending.push_back(PesFrame::from_codec_frame(*track, frame));
self.pending
.push_back(PesFrame::from_codec_frame(*track, frame));
}
}
}
@@ -86,10 +90,13 @@ impl<R: Read> TsDemuxReader<R> {
/// Codec private data for a track.
pub fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self.pid_to_track.iter()
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers.iter()
self.parsers
.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
+15 -6
View File
@@ -28,7 +28,8 @@ impl PesFrame {
if self.data.len() > u32::MAX as usize {
return Err(crate::error::Error::PesFrameTooLarge {
size: self.data.len(),
}.into());
}
.into());
}
w.write_all(&[self.track as u8])?;
w.write_all(&self.pts.to_le_bytes())?;
@@ -49,8 +50,7 @@ impl PesFrame {
}
let track = header[0] as usize;
let pts = i64::from_le_bytes([
header[1], header[2], header[3], header[4],
header[5], header[6], header[7], header[8],
header[1], header[2], header[3], header[4], header[5], header[6], header[7], header[8],
]);
let keyframe = header[9] != 0;
let len = u32::from_le_bytes([header[10], header[11], header[12], header[13]]) as usize;
@@ -59,7 +59,12 @@ impl PesFrame {
}
let mut data = vec![0u8; len];
r.read_exact(&mut data)?;
Ok(Some(Self { track, pts, keyframe, data }))
Ok(Some(Self {
track,
pts,
keyframe,
data,
}))
}
/// Create from a codec::Frame with a track index.
@@ -88,10 +93,14 @@ pub trait Stream {
fn info(&self) -> &crate::disc::DiscTitle;
/// Codec initialization data for a track (SPS/PPS, etc).
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> { None }
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> {
None
}
/// True when codec_private is available for all video tracks.
fn headers_ready(&self) -> bool { true }
fn headers_ready(&self) -> bool {
true
}
}
/// Wraps any output stream and counts bytes written.
+133 -26
View File
@@ -1,4 +1,11 @@
//! Linux SCSI transport via SG_IO ioctl.
//! Linux SCSI transport via async sg write/poll/read.
//!
//! Uses the sg driver's asynchronous interface instead of the blocking
//! SG_IO ioctl. Commands are submitted via write(), waited on via
//! poll() with a hard timeout, and completed via read(). If poll()
//! times out, the fd is abandoned (closed in a background thread) and
//! a fresh fd is opened. This gives us true user-controlled timeouts
//! that the kernel's USB error recovery cannot override.
use super::{DataDirection, ScsiResult, ScsiTransport};
use crate::error::{Error, Result};
@@ -10,7 +17,6 @@ const SG_SCSI_RESET_DEVICE: i32 = 1;
const SG_DXFER_NONE: i32 = -1;
const SG_DXFER_TO_DEV: i32 = -2;
const SG_DXFER_FROM_DEV: i32 = -3;
const SG_FLAG_DIRECT_IO: u32 = 1;
const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
#[repr(C)]
@@ -49,6 +55,7 @@ const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
pub struct SgIoTransport {
fd: i32,
device_path: std::path::PathBuf,
}
impl SgIoTransport {
@@ -61,13 +68,16 @@ impl SgIoTransport {
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Self::open_error(&device);
}
Ok(SgIoTransport { fd })
Ok(SgIoTransport {
fd,
device_path: device,
})
}
/// Reset the drive to a known good state — equivalent to unplug/replug.
@@ -116,7 +126,7 @@ impl SgIoTransport {
let probe_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if probe_fd >= 0 {
@@ -130,7 +140,7 @@ impl SgIoTransport {
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
@@ -166,7 +176,10 @@ impl SgIoTransport {
let err = std::io::Error::last_os_error();
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
Error::DevicePermission {
path: format!("{}: permission denied (try running as root)", device.display()),
path: format!(
"{}: permission denied (try running as root)",
device.display()
),
}
} else {
Error::DeviceNotFound {
@@ -176,7 +189,8 @@ impl SgIoTransport {
}
/// Send a raw SCSI command on an fd. Used by reset() before the
/// transport is constructed.
/// transport is constructed. Uses synchronous SG_IO — fine for
/// short commands (TUR, PREVENT MEDIUM REMOVAL, START/STOP).
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> std::result::Result<(), ()> {
let mut sense = [0u8; 32];
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
@@ -225,10 +239,7 @@ impl SgIoTransport {
if let Ok(mut entries) = std::fs::read_dir(&sg_dir) {
if let Some(Ok(entry)) = entries.next() {
let sg_name = entry.file_name();
return std::path::PathBuf::from(format!(
"/dev/{}",
sg_name.to_string_lossy()
));
return std::path::PathBuf::from(format!("/dev/{}", sg_name.to_string_lossy()));
}
}
}
@@ -239,13 +250,30 @@ impl SgIoTransport {
impl Drop for SgIoTransport {
fn drop(&mut self) {
// Unlock tray before closing — don't leave it locked
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
unsafe { libc::close(self.fd) };
if self.fd >= 0 {
// Unlock tray before closing — don't leave it locked
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
unsafe { libc::close(self.fd) };
}
}
}
impl ScsiTransport for SgIoTransport {
/// Execute a SCSI command with an enforceable timeout.
///
/// Uses the sg driver's async write/poll/read interface:
/// 1. write() submits the command — returns immediately
/// 2. poll() waits for completion — respects our timeout exactly
/// 3. read() retrieves the result — copies data to caller's buffer
///
/// If poll() times out, the pending command is abandoned: the old fd
/// is closed in a background thread (may block while kernel finishes
/// the USB transfer) and a fresh fd is opened. The caller sees a
/// normal SCSI error and can retry.
///
/// Without SG_FLAG_DIRECT_IO, the kernel uses internal buffers for
/// DMA and copies to userspace during read(). On timeout (no read),
/// the caller's buffer is untouched — safe to return immediately.
fn execute(
&mut self,
cdb: &[u8],
@@ -253,6 +281,12 @@ impl ScsiTransport for SgIoTransport {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
if self.fd < 0 {
return Err(Error::DeviceNotFound {
path: self.device_path.display().to_string(),
});
}
let mut sense = [0u8; 32];
let dxfer_direction = match direction {
@@ -281,18 +315,84 @@ impl ScsiTransport for SgIoTransport {
hdr.cmdp = cdb.as_ptr();
hdr.sbp = sense.as_mut_ptr();
hdr.timeout = timeout_ms;
if dxfer_direction == SG_DXFER_FROM_DEV
&& data.len() >= 4096
&& (data.as_ptr() as usize) % 4096 == 0
{
hdr.flags = SG_FLAG_DIRECT_IO | SG_FLAG_Q_AT_HEAD;
} else {
hdr.flags = SG_FLAG_Q_AT_HEAD;
hdr.flags = SG_FLAG_Q_AT_HEAD;
// Submit command asynchronously via write()
let hdr_size = std::mem::size_of::<sg_io_hdr>();
let wr = unsafe {
libc::write(
self.fd,
&hdr as *const sg_io_hdr as *const libc::c_void,
hdr_size,
)
};
if wr < 0 {
return Err(Error::IoError {
source: std::io::Error::last_os_error(),
});
}
let ret = unsafe { libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
// Wait for completion with enforceable timeout.
// Retry on EINTR (signal interrupted poll) with remaining time.
let deadline =
std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
let pr = loop {
let remaining = deadline
.saturating_duration_since(std::time::Instant::now())
.as_millis() as i32;
if remaining <= 0 {
break 0; // expired
}
let mut pfd = libc::pollfd {
fd: self.fd,
events: libc::POLLIN,
revents: 0,
};
let ret = unsafe { libc::poll(&mut pfd, 1, remaining) };
if ret >= 0 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
break ret;
}
};
if ret < 0 {
if pr <= 0 {
// Timeout (0) or fatal poll error (-1).
// Command is still pending in the kernel. Abandon this fd and
// open a fresh one. The old fd is closed in a background thread
// because close() blocks until the kernel completes/aborts the
// pending command.
let old_fd = self.fd;
self.fd = -1;
std::thread::spawn(move || {
unsafe { libc::close(old_fd) };
});
let c_path = Self::to_c_path(&self.device_path);
let new_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
self.fd = if new_fd >= 0 { new_fd } else { -1 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
});
}
// Read response — copies data from kernel buffer to caller's buffer
let rd = unsafe {
libc::read(
self.fd,
&mut hdr as *mut sg_io_hdr as *mut libc::c_void,
hdr_size,
)
};
if rd < 0 {
return Err(Error::IoError {
source: std::io::Error::last_os_error(),
});
@@ -301,8 +401,15 @@ impl ScsiTransport for SgIoTransport {
let bytes_transferred = (data.len() as i32).saturating_sub(hdr.resid).max(0) as usize;
if hdr.status != 0 {
let sense_key = if hdr.sb_len_wr > 2 {
sense[2] & 0x0F
let sense_key = if hdr.sb_len_wr >= 3 {
let response_code = sense[0] & 0x7F;
if response_code == 0x72 || response_code == 0x73 {
// Descriptor format sense: sense key at byte 1
sense[1] & 0x0F
} else {
// Fixed format sense (0x70/0x71): sense key at byte 2
sense[2] & 0x0F
}
} else {
0
};
+3 -2
View File
@@ -455,8 +455,9 @@ fn walk_to_authoring_device(start: IOObject) -> Option<IOObject> {
// 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) };
let kr = unsafe {
IORegistryEntryGetParentEntry(current, c"IOService".as_ptr() as *const u8, &mut parent)
};
if current != start {
unsafe { IOObjectRelease(current) };
+23 -5
View File
@@ -10,10 +10,20 @@ use crate::error::Result;
pub trait SectorReader: Send {
/// Read `count` sectors starting at `lba` into `buf`.
/// `buf` must be at least `count * 2048` bytes.
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
/// `recovery`: true = full retry/reset loop (ripping), false = single attempt (verify).
/// File-backed readers ignore the flag.
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize>;
/// Total capacity in sectors, if known.
fn capacity(&self) -> u32 { 0 }
fn capacity(&self) -> u32 {
0
}
}
/// SectorReader backed by a file (ISO image).
@@ -43,13 +53,21 @@ impl FileSectorReader {
}
impl SectorReader for FileSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
use std::io::{Read, Seek, SeekFrom};
let offset = lba as u64 * 2048;
let bytes = count as usize * 2048;
self.file.seek(SeekFrom::Start(offset))
self.file
.seek(SeekFrom::Start(offset))
.map_err(|e| crate::error::Error::IoError { source: e })?;
self.file.read_exact(&mut buf[..bytes])
self.file
.read_exact(&mut buf[..bytes])
.map_err(|e| crate::error::Error::IoError { source: e })?;
Ok(bytes)
}
+132 -1
View File
@@ -61,6 +61,11 @@ impl UdfFs {
self.metadata_start
}
/// Metadata partition size in sectors.
pub(crate) fn metadata_sectors(&self) -> u32 {
self.metadata_sectors
}
/// Find a directory by path (e.g. "/BDMV/PLAYLIST").
/// Path matching is case-insensitive.
pub fn find_dir(&self, path: &str) -> Option<&DirEntry> {
@@ -832,7 +837,133 @@ fn parse_dstring(data: &[u8]) -> String {
/// Read a single 2048-byte sector from the drive.
/// Uses standard READ(10) — no unlock required.
/// Buffered sector reader — reduces SCSI round-trips by pre-fetching blocks.
/// Each SCSI command has ~500ms overhead on USB drives, so reading 32 sectors
/// at once (one command) is 32x faster than 32 individual reads.
pub(crate) struct BufferedSectorReader<'a> {
inner: &'a mut dyn SectorReader,
cache_start: u32,
cache: Vec<u8>,
cache_sectors: u32,
batch: u16,
/// Pre-fetched sector data from bulk reads (sector ranges for AACS, MPLS, CLPI, etc.)
prefetched: std::collections::HashMap<u32, Vec<u8>>,
}
impl<'a> BufferedSectorReader<'a> {
pub(crate) fn new(inner: &'a mut dyn SectorReader, batch: u16) -> Self {
Self {
inner,
cache_start: u32::MAX,
cache: Vec::new(),
cache_sectors: 0,
batch,
prefetched: std::collections::HashMap::new(),
}
}
}
impl BufferedSectorReader<'_> {
/// Pre-read a contiguous range of sectors into the sliding cache.
/// Used to bulk-load the UDF metadata partition so subsequent reads are instant.
pub(crate) fn prefetch(&mut self, start_lba: u32, count: u32) {
let total = count as usize * 2048;
self.cache.resize(total, 0);
let mut offset = 0u32;
while offset < count {
let batch = (count - offset).min(self.batch as u32) as u16;
let buf_off = offset as usize * 2048;
if self
.inner
.read_sectors(
start_lba + offset,
batch,
&mut self.cache[buf_off..buf_off + batch as usize * 2048],
true,
)
.is_err()
{
break;
}
offset += batch as u32;
}
self.cache_start = start_lba;
self.cache_sectors = offset;
}
/// Pre-read multiple sector ranges into the permanent cache.
/// Each range is read in batch-sized chunks and stored per-sector in a HashMap.
/// Used to bulk-load all small files (AACS, MPLS, CLPI, META) before scanning.
pub(crate) fn prefetch_ranges(&mut self, ranges: &[(u32, u32)]) {
let mut tmp = vec![0u8; self.batch as usize * 2048];
for &(start, count) in ranges {
let mut offset = 0u32;
while offset < count {
let batch = (count - offset).min(self.batch as u32) as u16;
let bytes = batch as usize * 2048;
if self
.inner
.read_sectors(start + offset, batch, &mut tmp[..bytes], true)
.is_err()
{
break;
}
for i in 0..batch as u32 {
let s = i as usize * 2048;
self.prefetched
.insert(start + offset + i, tmp[s..s + 2048].to_vec());
}
offset += batch as u32;
}
}
}
}
impl SectorReader for BufferedSectorReader<'_> {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> std::result::Result<usize, crate::error::Error> {
if count == 1 {
// Check permanent prefetch cache first (HashMap)
if let Some(data) = self.prefetched.get(&lba) {
buf[..2048].copy_from_slice(data);
return Ok(2048);
}
// Check sliding cache
if lba >= self.cache_start && lba < self.cache_start + self.cache_sectors {
let offset = (lba - self.cache_start) as usize * 2048;
buf[..2048].copy_from_slice(&self.cache[offset..offset + 2048]);
return Ok(2048);
}
let block = self.batch;
self.cache.resize(block as usize * 2048, 0);
match self.inner.read_sectors(lba, block, &mut self.cache, true) {
Ok(_) => {
self.cache_start = lba;
self.cache_sectors = block as u32;
}
Err(_) => {
// Near end of disc or error — single sector fallback
self.cache.resize(2048, 0);
self.inner.read_sectors(lba, 1, &mut self.cache, true)?;
self.cache_start = lba;
self.cache_sectors = 1;
}
}
buf[..2048].copy_from_slice(&self.cache[..2048]);
Ok(2048)
} else {
// Multi-sector read — pass through
self.inner.read_sectors(lba, count, buf, true)
}
}
}
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
reader.read_sectors(lba, 1, buf)?;
reader.read_sectors(lba, 1, buf, true)?;
Ok(())
}
+240
View File
@@ -0,0 +1,240 @@
//! Disc sector verification — read every sector and classify health.
use crate::disc::{Chapter, DiscTitle};
use crate::sector::SectorReader;
use std::time::Instant;
/// Health status of a single sector read.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SectorStatus {
/// Read succeeded on first attempt, under 500ms
Good,
/// Read succeeded but took longer than expected (>500ms per sector)
Slow,
/// Read failed initially but succeeded on retry
Recovered,
/// Read failed all attempts — unrecoverable
Bad,
}
/// A contiguous range of bad or slow sectors.
#[derive(Debug, Clone)]
pub struct SectorRange {
pub start_lba: u32,
pub count: u32,
pub status: SectorStatus,
/// Byte offset from start of title
pub byte_offset: u64,
}
/// Result of a disc verification scan.
#[derive(Debug, Clone)]
pub struct VerifyResult {
pub total_sectors: u64,
pub good: u64,
pub slow: u64,
pub recovered: u64,
pub bad: u64,
/// Contiguous ranges of non-good sectors
pub ranges: Vec<SectorRange>,
pub elapsed_secs: f64,
}
impl VerifyResult {
/// Percentage of sectors that are fully readable (good + slow + recovered).
pub fn readable_pct(&self) -> f64 {
if self.total_sectors == 0 {
return 100.0;
}
(self.total_sectors - self.bad) as f64 / self.total_sectors as f64 * 100.0
}
/// True if every sector read successfully.
pub fn is_perfect(&self) -> bool {
self.bad == 0 && self.recovered == 0 && self.slow == 0
}
/// Map a bad sector range to a chapter timestamp.
pub fn chapter_at_offset(
chapters: &[Chapter],
byte_offset: u64,
duration_secs: f64,
total_bytes: u64,
) -> Option<(usize, f64)> {
if total_bytes == 0 || chapters.is_empty() {
return None;
}
let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
let mut chapter_idx = 0;
for (i, ch) in chapters.iter().enumerate() {
if ch.time_secs <= time_secs {
chapter_idx = i;
} else {
break;
}
}
Some((chapter_idx + 1, time_secs))
}
}
/// Progress callback: (sectors_done, total_sectors, current_status)
/// Return false to stop verification early.
pub type ProgressFn = Box<dyn FnMut(u64, u64, SectorStatus) -> bool>;
/// Verify all sectors in a title's extents.
/// Reads in batches for speed, falls back to single-sector on failure.
/// The progress callback returns false to request early stop.
pub fn verify_title(
reader: &mut dyn SectorReader,
title: &DiscTitle,
batch_sectors: u16,
mut on_progress: Option<ProgressFn>,
) -> VerifyResult {
let start = Instant::now();
let mut good: u64 = 0;
let mut slow: u64 = 0;
let mut recovered: u64 = 0;
let mut bad: u64 = 0;
let mut ranges: Vec<SectorRange> = Vec::new();
let mut sectors_done: u64 = 0;
let mut _stopped = false;
let mut byte_offset: u64 = 0;
let total_sectors: u64 = title.extents.iter().map(|e| e.sector_count as u64).sum();
let mut buf = vec![0u8; batch_sectors as usize * 2048];
'outer: for ext in &title.extents {
let mut offset: u32 = 0;
while offset < ext.sector_count {
let remaining = ext.sector_count - offset;
let count = remaining.min(batch_sectors as u32) as u16;
let lba = ext.start_lba + offset;
let bytes = count as usize * 2048;
let batch_start = Instant::now();
let batch_ok = reader
.read_sectors(lba, count, &mut buf[..bytes], false)
.is_ok();
let batch_ms = batch_start.elapsed().as_millis();
if batch_ok {
// Batch succeeded — classify by speed
let status = if batch_ms > count as u128 * 500 {
SectorStatus::Slow
} else {
SectorStatus::Good
};
match status {
SectorStatus::Good => good += count as u64,
SectorStatus::Slow => {
slow += count as u64;
ranges.push(SectorRange {
start_lba: lba,
count: count as u32,
status: SectorStatus::Slow,
byte_offset,
});
}
_ => {}
}
sectors_done += count as u64;
if let Some(ref mut cb) = on_progress {
if !cb(sectors_done, total_sectors, status) {
_stopped = true;
break 'outer;
}
}
} else {
// Batch failed — test each sector individually
for i in 0..count {
let sector_lba = lba + i as u32;
let sector_offset = i as usize * 2048;
let sector_byte_offset = byte_offset + i as u64 * 2048;
let s1 = Instant::now();
let first_ok = reader
.read_sectors(
sector_lba,
1,
&mut buf[sector_offset..sector_offset + 2048],
false,
)
.is_ok();
let s1_ms = s1.elapsed().as_millis();
let status = if first_ok && s1_ms <= 500 {
good += 1;
SectorStatus::Good
} else if first_ok {
slow += 1;
SectorStatus::Slow
} else {
// Retry once more after brief pause
std::thread::sleep(std::time::Duration::from_secs(2));
if reader
.read_sectors(
sector_lba,
1,
&mut buf[sector_offset..sector_offset + 2048],
false,
)
.is_ok()
{
recovered += 1;
SectorStatus::Recovered
} else {
bad += 1;
SectorStatus::Bad
}
};
if status != SectorStatus::Good {
// Merge with previous range if contiguous and same status
if let Some(last) = ranges.last_mut() {
if last.status == status && last.start_lba + last.count == sector_lba {
last.count += 1;
} else {
ranges.push(SectorRange {
start_lba: sector_lba,
count: 1,
status,
byte_offset: sector_byte_offset,
});
}
} else {
ranges.push(SectorRange {
start_lba: sector_lba,
count: 1,
status,
byte_offset: sector_byte_offset,
});
}
}
sectors_done += 1;
if let Some(ref mut cb) = on_progress {
if !cb(sectors_done, total_sectors, status) {
_stopped = true;
break 'outer;
}
}
}
}
offset += count as u32;
byte_offset += bytes as u64;
}
}
VerifyResult {
total_sectors,
good,
slow,
recovered,
bad,
ranges,
elapsed_secs: start.elapsed().as_secs_f64(),
}
}
+35 -81
View File
@@ -11,51 +11,29 @@ use libfreemkv::css;
// ── CSS Public API Tests ────────────────────────────────────────────────────
/// Test: css_descramble_sector_roundtrip_via_public_api
///
/// The public css::descramble_sector() wraps the LFSR descrambler.
/// Since the cipher is XOR-based, calling descramble twice (with restored
/// flags) should roundtrip the data.
/// Test: css_descramble_sector modifies encrypted region and preserves header.
#[test]
fn css_descramble_sector_roundtrip_via_public_api() {
let state = css::CssState {
title_key: [0x42, 0x13, 0x37, 0xBE, 0xEF],
};
// Build a sector with scramble flag set
let mut sector = vec![0x00u8; 2048];
sector[0x14] = 0x30; // scramble flag
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]); // seed
// PES header at byte 128
sector[0x80] = 0x00;
sector[0x81] = 0x00;
sector[0x82] = 0x01;
sector[0x83] = 0xE0;
// Fill content
for (i, byte) in sector.iter_mut().enumerate().take(2048).skip(0x84) {
*byte = (i & 0xFF) as u8;
}
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
let original = sector.clone();
// First descramble
css::descramble_sector(&state, &mut sector);
assert_eq!(sector[0x14] & 0x30, 0x00, "flag not cleared");
assert_ne!(
&sector[0x80..0x84],
&original[0x80..0x84],
"content unchanged"
);
// Restore flag for second pass
sector[0x14] = 0x30;
// Second descramble = roundtrip
css::descramble_sector(&state, &mut sector);
assert_eq!(
&sector[0x80..2048],
&original[0x80..2048],
"double descramble did not roundtrip"
);
// Header preserved (except flag byte)
for i in 0..128 {
if i == 0x14 {
continue;
}
assert_eq!(sector[i], original[i], "header byte {} changed", i);
}
// Encrypted region modified
assert_ne!(&sector[128..256], &original[128..256]);
}
/// Test: css_is_scrambled detects scramble flags correctly.
@@ -508,57 +486,34 @@ fn aacs_bus_decrypt_cross_validation() {
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
/// CSS descramble is XOR-based: applying it twice with restored scramble
/// flag must recover the original plaintext. This test uses a structured
/// MPEG-2 sector and stores a snapshot of the intermediate ciphertext to
/// catch any regressions in the cipher implementation.
/// CSS descramble modifies encrypted region deterministically.
#[test]
fn css_roundtrip_with_snapshot() {
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed: [u8; 5] = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
let mut sector = vec![0x00u8; 2048];
sector[0] = 0x00;
sector[1] = 0x00;
sector[2] = 0x01;
sector[3] = 0xBA;
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&seed);
sector[0x80] = 0x00;
sector[0x81] = 0x00;
sector[0x82] = 0x01;
sector[0x83] = 0xE0;
sector[0x84] = 0x07;
sector[0x85] = 0xEC;
sector[0x86] = 0x80;
sector[0x87] = 0x80;
sector[0x88] = 0x05;
sector[0x89] = 0x21;
for i in 0x8A..2048 {
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
for i in 0x80..2048 {
sector[i] = ((i * 7 + 3) & 0xFF) as u8;
}
let original = sector.clone();
// First descramble = "encrypt" via XOR
css::lfsr::descramble_sector(&title_key, &mut sector);
// Snapshot the first 32 bytes of the encrypted region for regression
let snapshot: Vec<u8> = sector[0x80..0xA0].to_vec();
assert_eq!(snapshot.len(), 32);
assert_eq!(sector[0x14] & 0x30, 0x00, "flag not cleared");
assert_ne!(&sector[0x80..0xA0], &original[0x80..0xA0]);
// Restore scramble flag and roundtrip
sector[0x14] = 0x30;
css::lfsr::descramble_sector(&title_key, &mut sector);
// Determinism: same input → same output
let mut sector2 = original.clone();
css::lfsr::descramble_sector(&title_key, &mut sector2);
assert_eq!(
&sector[0x80..2048],
&original[0x80..2048],
"CSS roundtrip failed"
&sector2[0x80..2048],
"not deterministic"
);
}
/// Multiple key/seed combinations to exercise different LFSR states.
/// Multiple key/seed combinations produce different outputs.
#[test]
fn css_roundtrip_multiple_keys() {
let cases: &[([u8; 5], [u8; 5])] = &[
@@ -580,26 +535,25 @@ fn css_roundtrip_multiple_keys() {
),
];
let mut results = Vec::new();
for (idx, (key, seed)) in cases.iter().enumerate() {
let mut sector = vec![0x00u8; 2048];
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(seed);
for i in 0x80..2048 {
sector[i] = ((i + idx) & 0xFF) as u8;
}
let original = sector.clone();
css::lfsr::descramble_sector(key, &mut sector);
assert_eq!(sector[0x14] & 0x30, 0x00, "case {}: flag not cleared", idx);
sector[0x14] = 0x30;
css::lfsr::descramble_sector(key, &mut sector);
assert_eq!(
&sector[0x80..2048],
&original[0x80..2048],
"case {}: roundtrip failed",
idx
);
results.push(sector[0x80..0xA0].to_vec());
}
// Different keys/seeds should produce different outputs
for i in 0..results.len() {
for j in (i + 1)..results.len() {
assert_ne!(
results[i], results[j],
"cases {} and {} produced same output",
i, j
);
}
}
}
+6 -2
View File
@@ -21,7 +21,7 @@ impl MockSectorReader {
}
impl SectorReader for MockSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
for i in 0..count as u32 {
let offset = i as usize * SECTOR_SIZE;
@@ -416,7 +416,11 @@ fn batch_count_u16_overflow_regression() {
// 47513600 = 725 * 65536, lower 16 bits = 0
let remaining: u32 = 47533152 - 19552; // = 47513600
assert_eq!(remaining, 47513600);
assert_eq!(remaining % 65536, 0, "remaining should be multiple of 65536");
assert_eq!(
remaining % 65536,
0,
"remaining should be multiple of 65536"
);
// Buggy version produces 0 → infinite loop
assert_eq!(buggy_batch_count(remaining, 60), 0);
+30 -6
View File
@@ -121,7 +121,11 @@ fn open_input_bare_path_errors() {
Err(e) => e.to_string(),
Ok(_) => panic!("expected error"),
};
assert!(msg.contains("not a valid stream URL") || msg.contains("E9002"), "got: {}", msg);
assert!(
msg.contains("not a valid stream URL") || msg.contains("E9002"),
"got: {}",
msg
);
}
#[test]
@@ -133,7 +137,11 @@ fn open_output_bare_path_errors() {
Err(e) => e.to_string(),
Ok(_) => panic!("expected error"),
};
assert!(msg.contains("not a valid stream URL") || msg.contains("E9002"), "got: {}", msg);
assert!(
msg.contains("not a valid stream URL") || msg.contains("E9002"),
"got: {}",
msg
);
}
#[test]
@@ -144,7 +152,11 @@ fn open_input_m2ts_empty_path_errors() {
Err(e) => e.to_string(),
Ok(_) => panic!("expected error"),
};
assert!(msg.contains("requires a file path") || msg.contains("E9003"), "got: {}", msg);
assert!(
msg.contains("requires a file path") || msg.contains("E9003"),
"got: {}",
msg
);
}
#[test]
@@ -155,7 +167,11 @@ fn open_output_null_input_errors() {
Err(e) => e.to_string(),
Ok(_) => panic!("expected error"),
};
assert!(msg.contains("write-only") || msg.contains("E9001"), "got: {}", msg);
assert!(
msg.contains("write-only") || msg.contains("E9001"),
"got: {}",
msg
);
}
#[test]
@@ -167,7 +183,11 @@ fn open_output_disc_errors() {
Err(e) => e.to_string(),
Ok(_) => panic!("expected error"),
};
assert!(msg.contains("read-only") || msg.contains("E9000"), "got: {}", msg);
assert!(
msg.contains("read-only") || msg.contains("E9000"),
"got: {}",
msg
);
}
#[test]
@@ -178,7 +198,11 @@ fn open_input_network_no_port_errors() {
Err(e) => e.to_string(),
Ok(_) => panic!("expected error"),
};
assert!(msg.contains("PES pipeline") || msg.contains("missing port") || msg.contains("E9004"), "got: {}", msg);
assert!(
msg.contains("PES pipeline") || msg.contains("missing port") || msg.contains("E9004"),
"got: {}",
msg
);
}
#[test]
+5 -5
View File
@@ -39,7 +39,7 @@ impl MockSectorReader {
}
impl SectorReader for MockSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
assert!(buf.len() >= total, "buffer too small");
for i in 0..count as u32 {
@@ -215,7 +215,7 @@ fn mock_sector_reader_roundtrip() {
// Read it back
let mut buf = vec![0u8; SECTOR_SIZE];
let n = reader.read_sectors(100, 1, &mut buf).unwrap();
let n = reader.read_sectors(100, 1, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 0xAB);
assert_eq!(buf[1], 0xCD);
@@ -223,7 +223,7 @@ fn mock_sector_reader_roundtrip() {
// Reading an unmapped sector returns zeros
let mut buf2 = vec![0xFFu8; SECTOR_SIZE];
let n2 = reader.read_sectors(999, 1, &mut buf2).unwrap();
let n2 = reader.read_sectors(999, 1, &mut buf2, true).unwrap();
assert_eq!(n2, SECTOR_SIZE);
assert_eq!(buf2[0], 0);
assert_eq!(buf2[2047], 0);
@@ -243,7 +243,7 @@ fn mock_sector_reader_multi_sector() {
// Read 2 consecutive sectors
let mut buf = vec![0u8; SECTOR_SIZE * 2];
let n = reader.read_sectors(10, 2, &mut buf).unwrap();
let n = reader.read_sectors(10, 2, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE * 2);
assert_eq!(buf[0], 10);
assert_eq!(buf[SECTOR_SIZE], 11);
@@ -463,7 +463,7 @@ fn sector_reader_is_object_safe() {
let dyn_reader: &mut dyn SectorReader = &mut reader;
let mut buf = vec![0u8; SECTOR_SIZE];
let n = dyn_reader.read_sectors(0, 1, &mut buf).unwrap();
let n = dyn_reader.read_sectors(0, 1, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 42);
}