mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aaddaa9b8 | ||
|
|
40ec1e1eba | ||
|
|
8843833ea7 | ||
|
|
afae37c8c3 | ||
|
|
d8089ec491 | ||
|
|
ce8132d181 | ||
|
|
71e1f57364 | ||
|
|
d8092ee093 | ||
|
|
dd49d55b11 | ||
|
|
6ba958fe8a | ||
|
|
f30b958408 | ||
|
|
6971f00be2 | ||
|
|
ad86f92d85 | ||
|
|
cce1be29b2 | ||
|
|
081ad4f619 | ||
|
|
43ecad2d43 | ||
|
|
8870efa77b | ||
|
|
a693ef965b | ||
|
|
0596307c19 | ||
|
|
18c6365e08 | ||
|
|
981f30b1b0 | ||
|
|
04086d73a0 | ||
|
|
20e404e59a | ||
|
|
1bea8eb650 | ||
|
|
0165f18fad | ||
|
|
ca3914d537 | ||
|
|
80fcffd190 | ||
|
|
7437af39c8 | ||
|
|
bcd6925170 | ||
|
|
fba1eb189c | ||
|
|
a328ad3cd8 | ||
|
|
9791e60c65 | ||
|
|
8b1ded84cd | ||
|
|
3e4febe7f5 | ||
|
|
aa7afc0c98 | ||
|
|
29f9e916d6 | ||
|
|
30d59063d5 | ||
|
|
6e958b0f71 | ||
|
|
911d260695 | ||
|
|
59d4eb8854 | ||
|
|
8820f7a460 | ||
|
|
8de99bb8e8 | ||
|
|
3e5e570573 | ||
|
|
f95894bba3 | ||
|
|
597f512eca | ||
|
|
8e907393a3 | ||
|
|
c47b9a9c3c | ||
|
|
d7b9d87075 | ||
|
|
e0f40583c4 | ||
|
|
d06a37d3dc |
+175
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 100
|
||||
+638
@@ -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
@@ -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
@@ -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!(
|
||||
§or[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!(
|
||||
§or[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!(§or[128..256], &original[128..256]);
|
||||
}
|
||||
|
||||
/// Test 4: css_tab1_relationship
|
||||
|
||||
+28
-18
@@ -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(§ors)?;
|
||||
|
||||
Some(CssState { title_key: key })
|
||||
None
|
||||
}
|
||||
|
||||
/// Descramble a single CSS-encrypted sector in place.
|
||||
|
||||
+13
-12
@@ -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
@@ -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
@@ -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()),
|
||||
})?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ impl PsDemuxer {
|
||||
|
||||
packets
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
||||
|
||||
+54
-57
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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!(
|
||||
§or[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!(
|
||||
§or[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!(§or[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!(§or[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!(
|
||||
§or[0x80..2048],
|
||||
&original[0x80..2048],
|
||||
"CSS roundtrip failed"
|
||||
§or2[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!(
|
||||
§or[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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user