mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e721ee7e | ||
|
|
481b47d90d | ||
|
|
dd7cbd70a9 | ||
|
|
3dea679dac | ||
|
|
64ecca0d12 | ||
|
|
84f6baba38 | ||
|
|
bd5b7795bd | ||
|
|
a57506c1ce | ||
|
|
c33f3e9557 | ||
|
|
166fc4bf8c | ||
|
|
9aaddaa9b8 | ||
|
|
40ec1e1eba | ||
|
|
8843833ea7 | ||
|
|
afae37c8c3 | ||
|
|
d8089ec491 | ||
|
|
ce8132d181 | ||
|
|
71e1f57364 | ||
|
|
d8092ee093 | ||
|
|
dd49d55b11 | ||
|
|
6ba958fe8a | ||
|
|
f30b958408 | ||
|
|
6971f00be2 | ||
|
|
ad86f92d85 | ||
|
|
cce1be29b2 | ||
|
|
081ad4f619 | ||
|
|
43ecad2d43 | ||
|
|
8870efa77b | ||
|
|
a693ef965b | ||
|
|
0596307c19 | ||
|
|
18c6365e08 | ||
|
|
981f30b1b0 | ||
|
|
04086d73a0 | ||
|
|
20e404e59a | ||
|
|
1bea8eb650 |
+134
-2
@@ -1,9 +1,141 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0 (2026-04-24)
|
||||
|
||||
### Rust 2024 edition migration
|
||||
- Bumped `edition = "2024"`. Required code changes:
|
||||
- FFI declarations in `src/scsi/macos.rs` wrapped in `unsafe extern "C" { … }` per the 2024 FFI safety rules.
|
||||
- `unsafe_op_in_unsafe_fn` lint: `vtable_fn()` body now has an explicit `unsafe { … }` block rather than relying on implicit unsafe of the containing `unsafe fn`.
|
||||
- Match-ergonomics: removed redundant `ref`/`ref mut` bindings in `mux/meta.rs`, `mux/mkvstream.rs`, `mux/network.rs`, `mux/stdio.rs` — 2024 tightens "cannot explicitly borrow within an implicitly-borrowing pattern."
|
||||
- No behavior change. MSRV stays at 1.86.
|
||||
|
||||
### Minor / version sync
|
||||
- Part of the 0.12.0 ecosystem release. The autorip-side fixes (progress regressions, UI redesign, regression-guard tests) drove the minor bump.
|
||||
|
||||
## 0.11.22 (2026-04-24)
|
||||
|
||||
### Version sync — no functional changes
|
||||
Part of the 0.11.22 ecosystem release. autorip 0.11.22 ships full multi-pass UI (bad-range viz, live mapfile stats, Recovery settings); the library API is unchanged from 0.11.21.
|
||||
|
||||
## 0.11.21 (2026-04-24)
|
||||
|
||||
### Multi-pass rip architecture — disc → ISO → patch → ISO
|
||||
|
||||
New primitives for two-stage rip: fast forward pass with zero-fill on failures, then targeted retries of bad ranges. Keeps the library API stream-based; the multi-pass model lives entirely in caller-orchestrated function composition.
|
||||
|
||||
- **New `Disc::copy(reader, path, &CopyOptions)`** replaces the positional-arg version. Always produces a ddrescue-format mapfile at `path + ".mapfile"` as a side-effect. With `skip_on_error=true` + `skip_forward=true`, does ddrescue-style fast sweep: 64 KB block reads, exponential skip-forward (256 KB → cap at 1% of disc) on failure, zero-fill bad blocks, record ranges in the mapfile. With defaults (both false), matches pre-0.11.21 behavior — uses drive-level recovery, aborts on bad sector. Mapfile is produced either way.
|
||||
- **New `Disc::patch(reader, path, &PatchOptions)`** — idempotent retry pass. Reads the mapfile, re-reads every non-`+` range with full drive recovery enabled, writes successful bytes back into the ISO at exact offsets, updates mapfile. Call N times for N retry attempts.
|
||||
- **New `disc::mapfile` module** — ddrescue-compatible plain-text format. Crash-safe (flushes on every `record()`), greppable, human-editable, tool-interoperable. Status chars match ddrescue: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished.
|
||||
- **Re-exports:** `FileSectorReader` from the crate root for ISO readers.
|
||||
|
||||
### Breaking changes
|
||||
- `Disc::copy`'s signature changes from positional args (`decrypt, resume, batch, on_progress`) to `CopyOptions`. Previous callers must migrate. `freemkv` CLI updated in lockstep.
|
||||
|
||||
### Version sync
|
||||
- Part of the 0.11.21 ecosystem release (libfreemkv + freemkv + bdemu + autorip all on 0.11.21).
|
||||
|
||||
## 0.11.18 (2026-04-24)
|
||||
|
||||
### DiscStream halt flag — Stop works during dense bad-sector regions
|
||||
|
||||
`DiscStream::fill_extents` loops internally when the demuxer hasn't accumulated enough data to emit a PES frame — during a dense bad-sector run, that loop can spend many minutes shrinking batch sizes and zero-filling sectors without ever returning to the outer read() call. Without an internal halt check, the caller's Stop request goes unserviced until the demuxer eventually emits a frame, which may be very far away.
|
||||
|
||||
- **`DiscStream::set_halt(Arc<AtomicBool>)`** — share a halt flag with the stream. Typically wired to `Drive::halt_flag()` so Stop propagates across both the drive's recovery phases and the stream's sector processing.
|
||||
- **`fill_extents()` checks the halt flag** at the top of every retry iteration (before each attempt at every size level). Raising the flag aborts within one read round-trip — at most the current SCSI command's timeout.
|
||||
- Returns `Err(Error::Halted)` (E6010) so the outer rip pipeline terminates cleanly.
|
||||
|
||||
No behavior change for callers that don't call `set_halt`. Unblocks the architectural fix for the "Stop doesn't stop" bug observed on a damaged UHD disc where the stream was stuck in a 12+ hour bad-sector grind.
|
||||
|
||||
## 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
|
||||
- **Strip BD-TS access unit header** — TrueHD parser was passing the 4-byte BD-TS access unit header into MKV. MKV expects raw MLP frames. The header bytes corrupted TrueHD sync, causing decode failures in all players. Affects BD and UHD discs with TrueHD/Atmos audio.
|
||||
### TrueHD fix (incomplete)
|
||||
- Initial attempt at TrueHD header stripping — wrong approach, superseded by 0.11.7.
|
||||
|
||||
## 0.11.5 (2026-04-18)
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.11.6"
|
||||
edition = "2021"
|
||||
version = "0.12.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
|
||||
@@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.10"
|
||||
libfreemkv = "0.11"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -51,6 +51,31 @@ while let Ok(Some(frame)) = input.read() {
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
### Multi-pass recovery rip
|
||||
|
||||
For damaged discs, the library offers a two-stage rip model: fast sweep with zero-fill and a ddrescue-format mapfile, then targeted retry of bad ranges. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for the full architecture.
|
||||
|
||||
```rust
|
||||
use libfreemkv::disc::{CopyOptions, PatchOptions};
|
||||
|
||||
// Pass 1: disc → ISO. Fast 64 KB reads, skip-forward on failure,
|
||||
// zero-fill bad blocks, write a sidecar .mapfile.
|
||||
let mut result = disc.copy(
|
||||
&mut drive,
|
||||
Path::new("disc.iso"),
|
||||
&CopyOptions { skip_on_error: true, skip_forward: true, ..Default::default() },
|
||||
)?;
|
||||
|
||||
// Pass 2..N: retry bad ranges with full drive recovery.
|
||||
// Idempotent — call as many times as you want.
|
||||
while result.bytes_unreadable + result.bytes_pending > 0 {
|
||||
let pr = disc.patch(&mut drive, Path::new("disc.iso"), &PatchOptions::default())?;
|
||||
if pr.bytes_recovered_this_pass == 0 { break; }
|
||||
}
|
||||
|
||||
// Then mux from the ISO via the normal stream pipeline (no drive involvement).
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, unlock, firmware upload, speed calibration, eject
|
||||
@@ -134,7 +159,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Rip recovery — multi-pass architecture
|
||||
|
||||
`libfreemkv` supports a two-stage rip model for damaged or protection-bearing
|
||||
discs: a fast forward sweep that tolerates read failures, followed by targeted
|
||||
retry passes against a persistent bad-range map. The stream pipeline
|
||||
(`DiscStream` + `input`/`output`) operates against the resulting ISO image, so
|
||||
the mux stage never touches the drive.
|
||||
|
||||
Three primitives compose the flow:
|
||||
|
||||
| Primitive | What it does |
|
||||
|---------------------------|-----------------------------------------------------------------------|
|
||||
| `Disc::copy` | disc → ISO. Writes a sidecar `.mapfile`. Opt-in skip-forward on failure. |
|
||||
| `Disc::patch` | Re-reads bad ranges from the drive. Idempotent; call N times. |
|
||||
| `DiscStream` (ISO source) | Reads sectors from the ISO, feeds decrypt → demux → codec → mux. |
|
||||
|
||||
The caller orchestrates. Autorip's `rip_disc` loops `copy` then N × `patch` per
|
||||
the `MAX_RETRIES` config, then hands the ISO off to the existing mux pipeline.
|
||||
|
||||
## Data model
|
||||
|
||||
### Mapfile
|
||||
|
||||
Format: [ddrescue](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)-compatible
|
||||
plain text, greppable, tool-interoperable. Flushed to disk on every `record()`
|
||||
so a crashed rip loses at most one block.
|
||||
|
||||
```
|
||||
# Rescue Logfile. Created by libfreemkv v0.11.22
|
||||
# Current pos / status / pass / pass_time
|
||||
0x000000000 ? 1 0
|
||||
# pos size status
|
||||
0x000000000 0x12a35d000 +
|
||||
0x12a35d000 0x000003000 -
|
||||
0x12a360000 0x009c4a000 +
|
||||
0x12d00a000 0x000064000 *
|
||||
```
|
||||
|
||||
Status characters match ddrescue:
|
||||
|
||||
| Char | Meaning |
|
||||
|------|----------------------------------------------------|
|
||||
| `?` | Not yet attempted |
|
||||
| `*` | Fast-pass failed; needs edge-trim |
|
||||
| `/` | Trimmed; interior needs sector scrape |
|
||||
| `-` | Unreadable this session |
|
||||
| `+` | Finished (good) |
|
||||
|
||||
Position and size are hex byte offsets into the ISO.
|
||||
|
||||
### `CopyOptions` and `PatchOptions`
|
||||
|
||||
Defaults preserve pre-`0.11.21` behavior — full drive recovery on every read,
|
||||
abort on the first unreadable sector. Opt in to the recovery-friendly path:
|
||||
|
||||
```rust
|
||||
CopyOptions {
|
||||
skip_on_error: true, // zero-fill bad blocks, continue
|
||||
skip_forward: true, // exponential skip-forward after a failure
|
||||
resume: true, // pick up from an existing ISO + mapfile
|
||||
decrypt: false, // keep the ISO a raw disc image
|
||||
..Default::default()
|
||||
}
|
||||
```
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Pass 1 — fast sweep
|
||||
|
||||
1. Read 64 KB (32 sectors, one BD ECC block) at the current LBA.
|
||||
2. On success: mark the range `+`, advance by one block.
|
||||
3. On failure (with `skip_on_error`): zero-fill the block in the ISO, mark
|
||||
it `*`, advance.
|
||||
4. If `skip_forward` is set: after a failure, jump ahead by an exponentially
|
||||
growing amount (256 KB initial, doubling on consecutive failures, capped at
|
||||
1% of disc). The skipped bytes are also marked `*` — `patch` will visit
|
||||
them later.
|
||||
5. Reset the skip size to 256 KB on the first success after a failure.
|
||||
|
||||
Pass 1 completes when every byte has terminal status (`+`, `-`, or the caller
|
||||
bails via the halt flag).
|
||||
|
||||
### Pass 2+ — patch
|
||||
|
||||
`Disc::patch` reads the mapfile and iterates every non-`+` range. For each:
|
||||
|
||||
1. Issue a drive read with full recovery enabled (SCSI-level retries,
|
||||
ECC recovery, the lot).
|
||||
2. On success: write the good bytes into the ISO at the exact byte offset,
|
||||
mark `+`.
|
||||
3. On failure: mark `-`.
|
||||
4. Update the mapfile after every block — crash-safe resume.
|
||||
|
||||
Idempotent. Call `patch` N times for N retry attempts; typically the caller
|
||||
stops early if a pass recovers zero bytes (structure-protected sectors will
|
||||
never yield).
|
||||
|
||||
## Design choices
|
||||
|
||||
**No `MODE SELECT` to disable drive retries.** Research showed neither ddrescue
|
||||
nor MakeMKV does this. Drive firmware has access to raw analog signal, laser
|
||||
power control, and drive-specific ECC tuning that userspace can't replicate —
|
||||
disabling it throws away recovery headroom on marginal sectors. We fail fast
|
||||
via short SG_IO timeouts instead, and we avoid per-sector probing on first
|
||||
contact by using large blocks + skip-forward.
|
||||
|
||||
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
|
||||
mux stage reads the ISO via `IsoSectorReader`. For single-pass (no retries),
|
||||
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
|
||||
re-muxability without re-ripping, and a persistent forensic artifact. Callers
|
||||
who need pure speed can bypass and use `DiscStream::new(Box::new(drive), …)`
|
||||
directly — the lib doesn't forbid it.
|
||||
|
||||
**Mapfile in ddrescue format.** Plain text so users can `less` it, `diff` it,
|
||||
or feed it to ddrescue's own tooling. Crash-safe (flush-per-record). Entries
|
||||
coalesce on adjacent same-status ranges so files stay small.
|
||||
|
||||
**Patches target `-`, `*`, `/`, and `?` alike.** The status state machine is
|
||||
ddrescue's but `patch` collapses the distinction — it just tries every
|
||||
non-finished range with full recovery. Future work can specialize (trim vs.
|
||||
scrape vs. retry with direction reversal) if there's measured benefit.
|
||||
|
||||
## References
|
||||
|
||||
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
|
||||
- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html)
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::copy`, `Disc::patch`)
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
|
||||
|
||||
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
// ── AACS constants ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -674,8 +674,8 @@ fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
|
||||
|
||||
/// AES-128-CMAC over 16 bytes of data.
|
||||
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
|
||||
|
||||
+1
-5
@@ -361,11 +361,7 @@ mod tests {
|
||||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if path.exists() { Some(path) } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+3
-7
@@ -661,18 +661,14 @@ pub fn resolve_keys(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::decrypt::{aes_ecb_encrypt, ALIGNED_UNIT_LEN};
|
||||
use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt};
|
||||
use super::super::keydb::{DiscEntry, KeyDb};
|
||||
use super::*;
|
||||
|
||||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if path.exists() { Some(path) } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -849,7 +845,7 @@ mod tests {
|
||||
data[23] = 1; // top_menu = CPS unit 1
|
||||
data[24] = 0;
|
||||
data[25] = 1; // num_titles = 1
|
||||
// Title 0 entry: 2 bytes pad + CPS unit
|
||||
// Title 0 entry: 2 bytes pad + CPS unit
|
||||
data[28] = 0;
|
||||
data[29] = 1; // CPS unit 1
|
||||
|
||||
|
||||
+5
-5
@@ -21,12 +21,12 @@ pub mod keys;
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, is_unit_encrypted,
|
||||
ALIGNED_UNIT_LEN,
|
||||
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_unit_encrypted,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::{
|
||||
decrypt_unit_key, derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash,
|
||||
disc_hash_hex, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
|
||||
resolve_keys, ContentCert, ResolvedKeys, UnitKeyFile,
|
||||
ContentCert, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_from_dk,
|
||||
derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_version,
|
||||
parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys,
|
||||
};
|
||||
|
||||
+2
-1
@@ -348,7 +348,7 @@ fn read_disc_key(drive: &mut Drive, agid: u8, bus_key: &[u8; 5]) -> Result<[u8;
|
||||
let candidate = super::lfsr::decrypt_key(0x00, player_key, &enc);
|
||||
|
||||
// Check if any previous candidate matches (same disc key from different entry/pk)
|
||||
for &(ref prev, _, _) in &candidates {
|
||||
for (prev, _, _) in &candidates {
|
||||
if *prev == candidate {
|
||||
return Ok(candidate);
|
||||
}
|
||||
@@ -391,6 +391,7 @@ fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<[u8; 5]>
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn read_title_key(
|
||||
drive: &mut Drive,
|
||||
agid: u8,
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ mod tests {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag set
|
||||
// Set a sector seed
|
||||
// Set a sector seed
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
|
||||
+5
-1
@@ -42,7 +42,11 @@ pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option<Cs
|
||||
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).is_ok() && is_scrambled(&buf) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! ddrescue-compatible mapfile for tracking rip progress.
|
||||
//!
|
||||
//! Records which byte ranges of a disc image are good, unreadable,
|
||||
//! or not-yet-attempted. Written as plain text so it's greppable,
|
||||
//! human-editable, and interoperates with ddrescue's own tools.
|
||||
//!
|
||||
//! Format:
|
||||
//! ```text
|
||||
//! # Rescue Logfile. Created by libfreemkv v0.11.21
|
||||
//! # Current pos / status / pass / pass_time (ddrescue state machine — we only populate pos)
|
||||
//! 0x000000000 ? 1 0
|
||||
//! # pos size status
|
||||
//! 0x000000000 0x12345678 +
|
||||
//! 0x012345678 0x00001000 -
|
||||
//! 0x012346678 0x01234500 ?
|
||||
//! ```
|
||||
//!
|
||||
//! Status chars: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished.
|
||||
//!
|
||||
//! The mapfile is flushed to disk on every `record()` call so a crashed
|
||||
//! rip loses at most one block of recorded state.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Status of a byte range in the mapfile. ddrescue-compatible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SectorStatus {
|
||||
/// `?` — not yet attempted. Initial state for a fresh mapfile.
|
||||
NonTried,
|
||||
/// `*` — fast-pass read failed; edges need trimming.
|
||||
NonTrimmed,
|
||||
/// `/` — trimmed; interior needs sector scrape.
|
||||
NonScraped,
|
||||
/// `-` — drive couldn't read it this session.
|
||||
Unreadable,
|
||||
/// `+` — good.
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl SectorStatus {
|
||||
pub fn to_char(self) -> char {
|
||||
match self {
|
||||
Self::NonTried => '?',
|
||||
Self::NonTrimmed => '*',
|
||||
Self::NonScraped => '/',
|
||||
Self::Unreadable => '-',
|
||||
Self::Finished => '+',
|
||||
}
|
||||
}
|
||||
pub fn from_char(c: char) -> Option<Self> {
|
||||
Some(match c {
|
||||
'?' => Self::NonTried,
|
||||
'*' => Self::NonTrimmed,
|
||||
'/' => Self::NonScraped,
|
||||
'-' => Self::Unreadable,
|
||||
'+' => Self::Finished,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One contiguous range of bytes with a status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MapEntry {
|
||||
pub pos: u64,
|
||||
pub size: u64,
|
||||
pub status: SectorStatus,
|
||||
}
|
||||
|
||||
/// Summary statistics over all entries.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MapStats {
|
||||
pub bytes_total: u64,
|
||||
pub bytes_good: u64,
|
||||
pub bytes_unreadable: u64,
|
||||
pub bytes_pending: u64,
|
||||
}
|
||||
|
||||
/// Write-through mapfile. Every `record()` persists to disk immediately
|
||||
/// so a crash during rip loses at most one block.
|
||||
pub struct Mapfile {
|
||||
path: PathBuf,
|
||||
entries: Vec<MapEntry>,
|
||||
total_size: u64,
|
||||
version: String,
|
||||
}
|
||||
|
||||
impl Mapfile {
|
||||
/// Create a new mapfile with one `NonTried` region covering the whole disc.
|
||||
/// Writes to disk immediately so a resume can pick up even if the caller
|
||||
/// never records anything.
|
||||
pub fn create(path: &Path, total_size: u64, version: &str) -> io::Result<Self> {
|
||||
let mf = Self {
|
||||
path: path.to_path_buf(),
|
||||
entries: vec![MapEntry {
|
||||
pos: 0,
|
||||
size: total_size,
|
||||
status: SectorStatus::NonTried,
|
||||
}],
|
||||
total_size,
|
||||
version: version.to_string(),
|
||||
};
|
||||
mf.write_to_disk()?;
|
||||
Ok(mf)
|
||||
}
|
||||
|
||||
/// Load an existing mapfile from disk.
|
||||
pub fn load(path: &Path) -> io::Result<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut entries = Vec::new();
|
||||
let mut saw_current_line = false;
|
||||
let mut version = String::from("unknown");
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix('#') {
|
||||
let rest = rest.trim();
|
||||
if let Some(v) = rest.strip_prefix("Rescue Logfile. Created by ") {
|
||||
version = v.to_string();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
|
||||
// We ignore its contents but skip over it.
|
||||
if !saw_current_line {
|
||||
saw_current_line = true;
|
||||
// But if the line looks like an entry (has at least 3 fields starting 0x...),
|
||||
// it's probably actually an entry for a mapfile we wrote without a current line.
|
||||
// Heuristic: current line has status char as 2nd field; entry has size as 2nd field.
|
||||
let fields: Vec<&str> = t.split_whitespace().collect();
|
||||
if fields.len() >= 3 && fields[1].starts_with("0x") {
|
||||
// It's an entry, not a current line — fall through to entry parse.
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Entry: `pos size statuschar`
|
||||
let fields: Vec<&str> = t.split_whitespace().collect();
|
||||
if fields.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let pos = parse_hex(fields[0])?;
|
||||
let size = parse_hex(fields[1])?;
|
||||
let status = fields[2]
|
||||
.chars()
|
||||
.next()
|
||||
.and_then(SectorStatus::from_char)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("bad status char in mapfile: {}", fields[2]),
|
||||
)
|
||||
})?;
|
||||
entries.push(MapEntry { pos, size, status });
|
||||
}
|
||||
entries.sort_by_key(|e| e.pos);
|
||||
let total_size = entries.last().map(|e| e.pos + e.size).unwrap_or(0);
|
||||
Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
entries,
|
||||
total_size,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load if the file exists, otherwise create a fresh mapfile.
|
||||
pub fn open_or_create(path: &Path, total_size: u64, version: &str) -> io::Result<Self> {
|
||||
match Self::load(path) {
|
||||
Ok(mf) => Ok(mf),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
Self::create(path, total_size, version)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a byte range as having the given status. Splits any overlapping
|
||||
/// existing entries, merges with adjacent same-status entries, and flushes
|
||||
/// to disk.
|
||||
pub fn record(&mut self, pos: u64, size: u64, status: SectorStatus) -> io::Result<()> {
|
||||
if size == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let end = pos.saturating_add(size);
|
||||
let mut new_entries = Vec::with_capacity(self.entries.len() + 2);
|
||||
|
||||
for e in self.entries.drain(..) {
|
||||
let e_end = e.pos + e.size;
|
||||
if e_end <= pos || e.pos >= end {
|
||||
// entirely before or after — keep
|
||||
new_entries.push(e);
|
||||
continue;
|
||||
}
|
||||
// Overlap — keep portions outside [pos, end)
|
||||
if e.pos < pos {
|
||||
new_entries.push(MapEntry {
|
||||
pos: e.pos,
|
||||
size: pos - e.pos,
|
||||
status: e.status,
|
||||
});
|
||||
}
|
||||
if e_end > end {
|
||||
new_entries.push(MapEntry {
|
||||
pos: end,
|
||||
size: e_end - end,
|
||||
status: e.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
new_entries.push(MapEntry { pos, size, status });
|
||||
new_entries.sort_by_key(|e| e.pos);
|
||||
|
||||
// Coalesce adjacent same-status entries.
|
||||
let mut merged: Vec<MapEntry> = Vec::with_capacity(new_entries.len());
|
||||
for e in new_entries {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
if last.pos + last.size == e.pos && last.status == e.status {
|
||||
last.size += e.size;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
merged.push(e);
|
||||
}
|
||||
|
||||
self.entries = merged;
|
||||
self.write_to_disk()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[MapEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn total_size(&self) -> u64 {
|
||||
self.total_size
|
||||
}
|
||||
|
||||
/// First range with a given status starting at or after `from`.
|
||||
pub fn next_with(&self, from: u64, status: SectorStatus) -> Option<(u64, u64)> {
|
||||
for e in &self.entries {
|
||||
if e.status != status {
|
||||
continue;
|
||||
}
|
||||
let e_end = e.pos + e.size;
|
||||
if e_end <= from {
|
||||
continue;
|
||||
}
|
||||
let start = e.pos.max(from);
|
||||
return Some((start, e_end - start));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// All ranges matching one of the given statuses, in position order.
|
||||
pub fn ranges_with(&self, statuses: &[SectorStatus]) -> Vec<(u64, u64)> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| statuses.contains(&e.status))
|
||||
.map(|e| (e.pos, e.size))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> MapStats {
|
||||
let mut s = MapStats {
|
||||
bytes_total: self.total_size,
|
||||
..Default::default()
|
||||
};
|
||||
for e in &self.entries {
|
||||
match e.status {
|
||||
SectorStatus::Finished => s.bytes_good += e.size,
|
||||
SectorStatus::Unreadable => s.bytes_unreadable += e.size,
|
||||
SectorStatus::NonTried | SectorStatus::NonTrimmed | SectorStatus::NonScraped => {
|
||||
s.bytes_pending += e.size
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn write_to_disk(&self) -> io::Result<()> {
|
||||
// Write to a tempfile then rename for atomicity. Appending ".tmp"
|
||||
// rather than `with_extension` so we don't clobber the original
|
||||
// extension (which may already be ".mapfile").
|
||||
let tmp = {
|
||||
let mut s = self.path.clone().into_os_string();
|
||||
s.push(".tmp");
|
||||
PathBuf::from(s)
|
||||
};
|
||||
{
|
||||
let file = std::fs::File::create(&tmp)?;
|
||||
let mut w = std::io::BufWriter::new(file);
|
||||
writeln!(
|
||||
w,
|
||||
"# Rescue Logfile. Created by libfreemkv v{}",
|
||||
self.version
|
||||
)?;
|
||||
writeln!(w, "# Current pos / status / pass / pass_time")?;
|
||||
writeln!(w, "0x000000000 ? 1 0")?;
|
||||
writeln!(w, "# pos size status")?;
|
||||
for e in &self.entries {
|
||||
writeln!(
|
||||
w,
|
||||
"0x{:09x} 0x{:09x} {}",
|
||||
e.pos,
|
||||
e.size,
|
||||
e.status.to_char()
|
||||
)?;
|
||||
}
|
||||
w.flush()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad hex {s}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmpfile(tag: &str) -> PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static CTR: AtomicU64 = AtomicU64::new(0);
|
||||
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
||||
let name = format!(
|
||||
"libfreemkv-mapfile-test-{}-{}-{}.mapfile",
|
||||
std::process::id(),
|
||||
tag,
|
||||
n
|
||||
);
|
||||
std::env::temp_dir().join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_has_one_nontried_region() {
|
||||
let p = tmpfile("create_has_one_nontried_region");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
assert_eq!(mf.entries().len(), 1);
|
||||
assert_eq!(mf.entries()[0].pos, 0);
|
||||
assert_eq!(mf.entries()[0].size, 1000);
|
||||
assert_eq!(mf.entries()[0].status, SectorStatus::NonTried);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_splits_overlap() {
|
||||
let p = tmpfile("record_splits_overlap");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(200, 100, SectorStatus::Finished).unwrap();
|
||||
let es = mf.entries();
|
||||
assert_eq!(es.len(), 3);
|
||||
assert_eq!(
|
||||
(es[0].pos, es[0].size, es[0].status),
|
||||
(0, 200, SectorStatus::NonTried)
|
||||
);
|
||||
assert_eq!(
|
||||
(es[1].pos, es[1].size, es[1].status),
|
||||
(200, 100, SectorStatus::Finished)
|
||||
);
|
||||
assert_eq!(
|
||||
(es[2].pos, es[2].size, es[2].status),
|
||||
(300, 700, SectorStatus::NonTried)
|
||||
);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_coalesces_adjacent_same_status() {
|
||||
let p = tmpfile("record_coalesces_adjacent_same_status");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 100, SectorStatus::Finished).unwrap();
|
||||
mf.record(200, 100, SectorStatus::Finished).unwrap();
|
||||
// Entries: [0..100 NonTried, 100..300 Finished (merged), 300..1000 NonTried]
|
||||
let es = mf.entries();
|
||||
assert_eq!(es.len(), 3);
|
||||
assert_eq!(
|
||||
(es[1].pos, es[1].size, es[1].status),
|
||||
(100, 200, SectorStatus::Finished)
|
||||
);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_replaces_existing_status() {
|
||||
let p = tmpfile("record_replaces_existing_status");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(200, 100, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(200, 100, SectorStatus::Finished).unwrap();
|
||||
let es = mf.entries();
|
||||
// The overwrite should result in all finished at 200..300, NonTried elsewhere — 3 entries.
|
||||
assert_eq!(es.len(), 3);
|
||||
assert_eq!(es[1].status, SectorStatus::Finished);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_load() {
|
||||
let p = tmpfile("round_trip_load");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 200, SectorStatus::Finished).unwrap();
|
||||
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_sum_correctly() {
|
||||
let p = tmpfile("stats_sum_correctly");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 400, SectorStatus::Finished).unwrap();
|
||||
mf.record(400, 100, SectorStatus::Unreadable).unwrap();
|
||||
let s = mf.stats();
|
||||
assert_eq!(s.bytes_good, 400);
|
||||
assert_eq!(s.bytes_unreadable, 100);
|
||||
assert_eq!(s.bytes_pending, 500);
|
||||
assert_eq!(s.bytes_total, 1000);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_with_filters() {
|
||||
let p = tmpfile("ranges_with_filters");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 50, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(300, 50, SectorStatus::Unreadable).unwrap();
|
||||
let bad = mf.ranges_with(&[SectorStatus::Unreadable]);
|
||||
assert_eq!(bad, vec![(100, 50), (300, 50)]);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
+331
-78
@@ -11,6 +11,7 @@
|
||||
mod bluray;
|
||||
mod dvd;
|
||||
mod encrypt;
|
||||
pub mod mapfile;
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -874,8 +875,8 @@ impl KeySource {
|
||||
|
||||
/// Standard KEYDB.cfg search locations (compatible with libaacs).
|
||||
const KEYDB_SEARCH_PATHS: &[&str] = &[
|
||||
".config/aacs/KEYDB.cfg", // libaacs standard path
|
||||
".config/freemkv/keydb.cfg", // freemkv download path
|
||||
".config/aacs/KEYDB.cfg", // libaacs standard path
|
||||
".config/freemkv/keydb.cfg", // freemkv download path
|
||||
];
|
||||
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
|
||||
|
||||
@@ -938,9 +939,7 @@ pub struct DiscId {
|
||||
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)
|
||||
self.meta_title.as_deref().unwrap_or(&self.volume_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1031,10 +1030,12 @@ impl Disc {
|
||||
{
|
||||
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).is_ok() {
|
||||
if crate::css::is_scrambled(&buf) {
|
||||
return Some(ext.start_lba);
|
||||
}
|
||||
if session
|
||||
.read_sectors(ext.start_lba, 1, &mut buf, true)
|
||||
.is_ok()
|
||||
&& crate::css::is_scrambled(&buf)
|
||||
{
|
||||
return Some(ext.start_lba);
|
||||
}
|
||||
None
|
||||
});
|
||||
@@ -1108,6 +1109,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);
|
||||
@@ -1205,94 +1207,345 @@ impl Disc {
|
||||
|
||||
/// Raw sector copy — write the entire disc image to a file.
|
||||
///
|
||||
/// This is NOT a stream operation. It copies sectors 0→capacity byte-for-byte,
|
||||
/// producing a valid ISO/UDF image. The disc's filesystem structure is preserved.
|
||||
/// NOT a stream operation. Copies sectors 0→capacity byte-for-byte producing
|
||||
/// a valid ISO/UDF image. Records progress in a ddrescue-format mapfile at
|
||||
/// `path + ".mapfile"` — flushed every block for crash-safe resume.
|
||||
///
|
||||
/// If `decrypt` is true and keys are available, sectors are decrypted on the fly.
|
||||
/// If `resume` is true and the file already exists, resumes from the last safe position.
|
||||
///
|
||||
/// `on_progress` is called periodically with (bytes_done, total_bytes).
|
||||
/// # Options
|
||||
/// - **default** (all false): behavior matches pre-v0.11.21 — uses full
|
||||
/// drive recovery (may take minutes per bad sector), aborts on error.
|
||||
/// Mapfile is produced as a side-effect.
|
||||
/// - **skip_on_error**: zero-fill bad blocks in the ISO, mark them in the
|
||||
/// mapfile, and continue. Uses fast reads (no drive-level recovery loop).
|
||||
/// - **skip_forward** (implies skip_on_error): on block failure, also skip
|
||||
/// forward by an exponentially-growing amount, marking the jumped region
|
||||
/// as `non-trimmed` for later trimming/scraping by `Disc::patch`.
|
||||
/// - **resume**: if the mapfile exists, resume from its state — only
|
||||
/// `non-tried` ranges are read. Without `resume`, a fresh mapfile is
|
||||
/// written and the ISO recreated from scratch.
|
||||
pub fn copy(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
path: &std::path::Path,
|
||||
decrypt: bool,
|
||||
resume: bool,
|
||||
batch_sectors: Option<u16>,
|
||||
on_progress: Option<&dyn Fn(u64, u64)>,
|
||||
) -> Result<()> {
|
||||
opts: &CopyOptions,
|
||||
) -> Result<CopyResult> {
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
let total_bytes = self.capacity_sectors as u64 * 2048;
|
||||
let keys = if decrypt {
|
||||
let keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
|
||||
// Resume: check existing file
|
||||
let (start_lba, file) = if resume {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) if meta.len() > 0 => {
|
||||
let safe_sectors = (meta.len() / 2048).saturating_sub(5) as u32;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
let resume_pos = safe_sectors as u64 * 2048;
|
||||
f.set_len(resume_pos)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
f.seek(SeekFrom::End(0))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
(safe_sectors, f)
|
||||
}
|
||||
_ => {
|
||||
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 })?;
|
||||
(0u32, f)
|
||||
};
|
||||
|
||||
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
let batch: u16 = batch_sectors.unwrap_or(DEFAULT_BATCH_SECTORS);
|
||||
let mut lba = start_lba;
|
||||
let mut bytes_done = start_lba as u64 * 2048;
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
|
||||
while lba < self.capacity_sectors {
|
||||
let remaining = self.capacity_sectors - lba;
|
||||
let count = remaining.min(batch as u32) as u16;
|
||||
let bytes = count as usize * 2048;
|
||||
|
||||
reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes])
|
||||
.map_err(|e| Error::IoError {
|
||||
source: std::io::Error::other(e.to_string()),
|
||||
})?;
|
||||
|
||||
// Decrypt if requested
|
||||
if decrypt {
|
||||
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
||||
}
|
||||
|
||||
writer
|
||||
.write_all(&buf[..bytes])
|
||||
// Mapfile: load if resuming, else wipe + recreate.
|
||||
let mapfile_path = mapfile_path_for(path);
|
||||
if !opts.resume {
|
||||
let _ = std::fs::remove_file(&mapfile_path);
|
||||
}
|
||||
let mut map =
|
||||
mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, env!("CARGO_PKG_VERSION"))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
lba += count as u32;
|
||||
bytes_done += bytes as u64;
|
||||
// ISO file: if resuming and mapfile has Finished ranges, open existing;
|
||||
// otherwise create fresh and pre-size to total_bytes (sparse holes for
|
||||
// non-tried regions).
|
||||
let file = if opts.resume
|
||||
&& std::fs::metadata(path)
|
||||
.map(|m| m.len() > 0)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::IoError { source: e })?
|
||||
} else {
|
||||
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
|
||||
f.set_len(total_bytes)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
f
|
||||
};
|
||||
|
||||
if let Some(ref cb) = on_progress {
|
||||
cb(bytes_done, total_bytes);
|
||||
let mut file = file;
|
||||
let batch: u16 = match opts.batch_sectors {
|
||||
Some(b) => b,
|
||||
None if opts.skip_forward => 32, // 64 KB = BD ECC block size
|
||||
None => DEFAULT_BATCH_SECTORS,
|
||||
};
|
||||
|
||||
// Skip-forward state.
|
||||
let skip_init = 256 * 1024u64; // 256 KB
|
||||
let skip_max = (total_bytes / 100).max(skip_init); // cap at 1% of disc
|
||||
let mut skip_size = skip_init;
|
||||
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
let mut bytes_done = 0u64;
|
||||
let mut halt_requested = false;
|
||||
|
||||
// Iterate over not-yet-finished regions from the mapfile. We re-read the
|
||||
// mapfile after each block because record() mutates the region list.
|
||||
'outer: loop {
|
||||
let regions_to_do = map.ranges_with(&[
|
||||
mapfile::SectorStatus::NonTried,
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
mapfile::SectorStatus::NonScraped,
|
||||
]);
|
||||
if regions_to_do.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Only process the first NonTried range per outer pass; skip_forward
|
||||
// may turn others into NonTrimmed which we DO NOT re-enter here —
|
||||
// Disc::patch handles those.
|
||||
let Some((region_pos, region_size)) = map.next_with(0, mapfile::SectorStatus::NonTried)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let region_end = region_pos + region_size;
|
||||
let mut pos = region_pos;
|
||||
|
||||
while pos < region_end {
|
||||
if let Some(ref h) = opts.halt {
|
||||
if h.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
halt_requested = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
let block_bytes = (region_end - pos).min(batch as u64 * 2048);
|
||||
let lba = (pos / 2048) as u32;
|
||||
let count = (block_bytes / 2048) as u16;
|
||||
let bytes = count as usize * 2048;
|
||||
|
||||
let recovery = !opts.skip_on_error; // fast reads when skipping
|
||||
let read_ok = reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes], recovery)
|
||||
.is_ok();
|
||||
|
||||
if read_ok {
|
||||
if opts.decrypt {
|
||||
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
||||
}
|
||||
file.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
file.write_all(&buf[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
bytes_done = bytes_done.saturating_add(block_bytes);
|
||||
skip_size = skip_init; // reset after success
|
||||
pos += block_bytes;
|
||||
} else if opts.skip_on_error {
|
||||
// Zero-fill this block, mark non-trimmed for later patch trim.
|
||||
buf[..bytes].fill(0);
|
||||
file.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
file.write_all(&buf[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
pos += block_bytes;
|
||||
|
||||
if opts.skip_forward && pos < region_end {
|
||||
// Skip ahead; mark skipped bytes as non-trimmed too.
|
||||
let jump = skip_size.min(region_end - pos);
|
||||
if jump > 0 {
|
||||
map.record(pos, jump, mapfile::SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
pos += jump;
|
||||
}
|
||||
skip_size = (skip_size * 2).min(skip_max);
|
||||
}
|
||||
} else {
|
||||
// Current behavior (pre-0.11.21): abort on first bad sector.
|
||||
return Err(Error::DiscRead { sector: lba as u64 });
|
||||
}
|
||||
|
||||
if let Some(cb) = opts.on_progress {
|
||||
let stats = map.stats();
|
||||
cb(stats.bytes_good, total_bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.flush().map_err(|e| Error::IoError { source: e })?;
|
||||
Ok(())
|
||||
file.sync_all().map_err(|e| Error::IoError { source: e })?;
|
||||
let stats = map.stats();
|
||||
Ok(CopyResult {
|
||||
bytes_total: total_bytes,
|
||||
bytes_good: stats.bytes_good,
|
||||
bytes_unreadable: stats.bytes_unreadable,
|
||||
bytes_pending: stats.bytes_pending,
|
||||
complete: stats.bytes_pending == 0 && !halt_requested,
|
||||
halted: halt_requested,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for `Disc::copy`. All fields default to the pre-v0.11.21 behavior
|
||||
/// (recovery reads, abort on bad sector).
|
||||
#[derive(Default)]
|
||||
pub struct CopyOptions<'a> {
|
||||
pub decrypt: bool,
|
||||
/// Resume from existing mapfile + ISO if present. Without this, any
|
||||
/// existing mapfile is wiped and the ISO recreated.
|
||||
pub resume: bool,
|
||||
/// Override the default block size. Defaults to 32 sectors (64 KB) in
|
||||
/// `skip_forward` mode, `DEFAULT_BATCH_SECTORS` otherwise.
|
||||
pub batch_sectors: Option<u16>,
|
||||
/// Zero-fill bad blocks in the ISO, mark them in the mapfile, continue.
|
||||
/// Uses fast reads (no drive-level recovery loop).
|
||||
pub skip_on_error: bool,
|
||||
/// ddrescue-style exponential skip-forward on block failure. Implies
|
||||
/// `skip_on_error`. The skipped region is marked `non-trimmed` for later
|
||||
/// trimming/scraping by `Disc::patch`.
|
||||
pub skip_forward: bool,
|
||||
pub on_progress: Option<&'a dyn Fn(u64, u64)>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
/// Result of `Disc::copy`. `complete=true` means every byte reached a terminal
|
||||
/// state (Finished or Unreadable). `complete=false` means there's still pending
|
||||
/// work (halt, abort, or non-tried ranges) that `Disc::patch` or a resumed
|
||||
/// `Disc::copy` would continue.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CopyResult {
|
||||
pub bytes_total: u64,
|
||||
pub bytes_good: u64,
|
||||
pub bytes_unreadable: u64,
|
||||
pub bytes_pending: u64,
|
||||
pub complete: bool,
|
||||
pub halted: bool,
|
||||
}
|
||||
|
||||
/// Sidecar mapfile path for a given ISO path — `foo.iso` → `foo.iso.mapfile`.
|
||||
pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut s = iso_path.as_os_str().to_os_string();
|
||||
s.push(".mapfile");
|
||||
std::path::PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Options for `Disc::patch`. Idempotent — each call is one patch attempt.
|
||||
#[derive(Default)]
|
||||
pub struct PatchOptions<'a> {
|
||||
pub decrypt: bool,
|
||||
/// Sector-granularity block size for retries. Defaults to 1 sector (2 KB).
|
||||
pub block_sectors: Option<u16>,
|
||||
/// Use full drive-level recovery on each read (slow but thorough). Defaults
|
||||
/// to true — patch is the pass where we *want* the drive to try hard.
|
||||
pub full_recovery: bool,
|
||||
pub on_progress: Option<&'a dyn Fn(u64, u64)>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
/// Result of `Disc::patch` — how many bad bytes were recovered.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PatchResult {
|
||||
pub bytes_total: u64,
|
||||
pub bytes_good: u64,
|
||||
pub bytes_unreadable: u64,
|
||||
pub bytes_pending: u64,
|
||||
pub bytes_recovered_this_pass: u64,
|
||||
pub halted: bool,
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Patch an existing ISO using its sidecar mapfile. Re-reads every range
|
||||
/// that's not yet `+` (Finished) and writes successful bytes into the ISO
|
||||
/// at their exact offsets. Updates mapfile entries as it goes.
|
||||
///
|
||||
/// Idempotent — call repeatedly to apply more retry attempts. Stops early
|
||||
/// if a pass recovered zero bytes (no point continuing).
|
||||
pub fn patch(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
path: &std::path::Path,
|
||||
opts: &PatchOptions,
|
||||
) -> Result<PatchResult> {
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
let mapfile_path = mapfile_path_for(path);
|
||||
let mut map =
|
||||
mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?;
|
||||
let total_bytes = map.total_size();
|
||||
let keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
let block_sectors = opts.block_sectors.unwrap_or(1);
|
||||
// Patch always reads with full drive recovery — this is the pass where
|
||||
// we want the drive's ECC retry machinery. Consumers who want fast-fail
|
||||
// use Disc::copy with skip_on_error instead.
|
||||
let _ = opts.full_recovery;
|
||||
|
||||
let bytes_good_before = map.stats().bytes_good;
|
||||
let mut halted = false;
|
||||
let mut buf = vec![0u8; block_sectors as usize * 2048];
|
||||
|
||||
// Collect bad ranges up front. Iterating while mutating is fragile;
|
||||
// each recorded change is persisted, so resume works even if we crash
|
||||
// mid-loop.
|
||||
let bad_ranges = map.ranges_with(&[
|
||||
mapfile::SectorStatus::NonTried,
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
mapfile::SectorStatus::NonScraped,
|
||||
mapfile::SectorStatus::Unreadable,
|
||||
]);
|
||||
|
||||
'outer: for (range_pos, range_size) in bad_ranges {
|
||||
let mut pos = range_pos;
|
||||
let end = range_pos + range_size;
|
||||
while pos < end {
|
||||
if let Some(ref h) = opts.halt {
|
||||
if h.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
halted = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
let block_bytes = (end - pos).min(block_sectors as u64 * 2048);
|
||||
let lba = (pos / 2048) as u32;
|
||||
let count = (block_bytes / 2048) as u16;
|
||||
let bytes = count as usize * 2048;
|
||||
let read_ok = reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes], true)
|
||||
.is_ok();
|
||||
if read_ok {
|
||||
if opts.decrypt {
|
||||
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
||||
}
|
||||
file.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
file.write_all(&buf[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
} else {
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
pos += block_bytes;
|
||||
|
||||
if let Some(cb) = opts.on_progress {
|
||||
let s = map.stats();
|
||||
cb(s.bytes_good, total_bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.sync_all().map_err(|e| Error::IoError { source: e })?;
|
||||
let stats = map.stats();
|
||||
Ok(PatchResult {
|
||||
bytes_total: total_bytes,
|
||||
bytes_good: stats.bytes_good,
|
||||
bytes_unreadable: stats.bytes_unreadable,
|
||||
bytes_pending: stats.bytes_pending,
|
||||
bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before),
|
||||
halted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+202
-37
@@ -16,13 +16,16 @@ 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;
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
use crate::profile::{self, DriveProfile};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::sector::SectorReader;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// 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,69 @@ 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)
|
||||
}
|
||||
|
||||
/// Halt-aware sleep. Returns `Err(Halted)` if the flag fires before or
|
||||
/// during the wait. The only sleep the drive's recovery path is allowed
|
||||
/// to use — makes it impossible to accidentally block through a Stop.
|
||||
fn checked_sleep(&self, total: std::time::Duration) -> Result<()> {
|
||||
sleep_until_halted(&self.halt, total)
|
||||
}
|
||||
|
||||
/// Halt-aware SCSI execute. Returns `Err(Halted)` if the flag is set
|
||||
/// before the command dispatches or by the time it completes. The only
|
||||
/// path to talk to the drive in the recovery hot loop; keeps Drive::read
|
||||
/// free of explicit halt checks.
|
||||
fn checked_exec(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
buf: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<crate::scsi::ScsiResult> {
|
||||
if self.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let r = self.scsi.as_mut().execute(cdb, dir, buf, timeout_ms)?;
|
||||
if self.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Close the drive cleanly. Unlocks tray, flushes SCSI state, closes fd.
|
||||
/// Also runs automatically on Drop as a safety net.
|
||||
pub fn close(self) {
|
||||
@@ -458,8 +524,17 @@ 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 {
|
||||
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
|
||||
// Non-recovery mode is the Disc::copy fast pass — intent is "fail
|
||||
// fast, let skip_forward advance past bad regions." A 5s budget let
|
||||
// the drive grind L-EC on structure-protected / marginal sectors for
|
||||
// nearly the full interval per 64 KB block, pinning throughput at
|
||||
// ~13 KB/s on difficult discs. 1500ms kills slow reads early so
|
||||
// skip_forward can double its stride; recoverable sectors are picked
|
||||
// up on Disc::patch where recovery=true and the timeout is 30s.
|
||||
let timeout_ms = if !recovery {
|
||||
1_500
|
||||
} else if self.recovery_bytes_remaining > 0 {
|
||||
30_000
|
||||
} else {
|
||||
10_000
|
||||
@@ -477,36 +552,51 @@ impl Drive {
|
||||
0x00,
|
||||
];
|
||||
|
||||
// Normal read
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
// Normal read. Fast path: drive returns data, we return it.
|
||||
// checked_exec short-circuits to Err(Halted) if Stop was requested;
|
||||
// read() never touches the halt flag directly.
|
||||
match self.checked_exec(
|
||||
&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.set_speed(0xFFFF);
|
||||
Ok(result) => {
|
||||
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);
|
||||
}
|
||||
return Ok(result.bytes_transferred);
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
Err(_) => { /* fall through to recovery */ }
|
||||
}
|
||||
|
||||
// Phase 1: gentle — sleep 30s, retry. 5 times.
|
||||
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 {
|
||||
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,
|
||||
) {
|
||||
// Phase 1: gentle — sleep 30 s, retry. 5 times.
|
||||
for attempt in 1..=5u32 {
|
||||
self.emit(EventKind::Retry { attempt });
|
||||
self.checked_sleep(std::time::Duration::from_secs(30))?;
|
||||
if let Ok(result) =
|
||||
self.checked_exec(&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);
|
||||
}
|
||||
@@ -514,24 +604,22 @@ impl Drive {
|
||||
|
||||
// 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));
|
||||
self.checked_sleep(std::time::Duration::from_secs(5))?;
|
||||
let _ = crate::scsi::reset(&device);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
self.checked_sleep(std::time::Duration::from_secs(5))?;
|
||||
self.scsi = crate::scsi::open(&device)?;
|
||||
let _ = self.init();
|
||||
let _ = self.wait_ready();
|
||||
self.set_speed(0);
|
||||
|
||||
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
|
||||
for _ in 0..5 {
|
||||
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,
|
||||
) {
|
||||
// Phase 3: gentle again on fresh connection — sleep 30 s, retry. 5 times.
|
||||
for attempt in 6..=10u32 {
|
||||
self.emit(EventKind::Retry { attempt });
|
||||
self.checked_sleep(std::time::Duration::from_secs(30))?;
|
||||
if let Ok(result) =
|
||||
self.checked_exec(&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);
|
||||
}
|
||||
@@ -640,8 +728,35 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Halt-aware sleep primitive. Returns `Err(Halted)` if the flag is set
|
||||
/// before or during the wait. Extracted as a free function so it can be
|
||||
/// unit-tested without constructing a full `Drive`.
|
||||
///
|
||||
/// Wakes within ~100 ms of a halt, regardless of the requested duration.
|
||||
fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<()> {
|
||||
const SLICE: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
let deadline = std::time::Instant::now() + total;
|
||||
loop {
|
||||
if halt.load(Ordering::Relaxed) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return Ok(());
|
||||
}
|
||||
let remaining = deadline - now;
|
||||
std::thread::sleep(remaining.min(SLICE));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,3 +822,53 @@ fn create_driver(
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod halt_tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn sleep_until_halted_completes_when_not_halted() {
|
||||
let flag = AtomicBool::new(false);
|
||||
let t0 = Instant::now();
|
||||
let r = sleep_until_halted(&flag, Duration::from_millis(150));
|
||||
assert!(r.is_ok());
|
||||
assert!(t0.elapsed() >= Duration::from_millis(140));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_until_halted_returns_immediately_if_preflagged() {
|
||||
let flag = AtomicBool::new(true);
|
||||
let t0 = Instant::now();
|
||||
let r = sleep_until_halted(&flag, Duration::from_secs(10));
|
||||
assert!(matches!(r, Err(Error::Halted)));
|
||||
// Must wake within one slice (100 ms) — the whole point of the
|
||||
// primitive is that a 30 s sleep doesn't block Stop.
|
||||
assert!(t0.elapsed() < Duration::from_millis(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_until_halted_wakes_mid_sleep() {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let f2 = flag.clone();
|
||||
let t0 = Instant::now();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
f2.store(true, Ordering::Relaxed);
|
||||
});
|
||||
let r = sleep_until_halted(&flag, Duration::from_secs(10));
|
||||
assert!(matches!(r, Err(Error::Halted)));
|
||||
let waited = t0.elapsed();
|
||||
// Flag flipped at ~150 ms; we wake within one 100 ms slice → <300 ms.
|
||||
assert!(waited < Duration::from_millis(350), "waited {waited:?}");
|
||||
assert!(waited >= Duration::from_millis(140), "waited {waited:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_until_halted_zero_duration_is_noop_when_not_halted() {
|
||||
let flag = AtomicBool::new(false);
|
||||
let r = sleep_until_halted(&flag, Duration::ZERO);
|
||||
assert!(r.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -134,6 +135,8 @@ pub enum Error {
|
||||
DiscRead {
|
||||
sector: u64,
|
||||
},
|
||||
/// Drive was halted by caller.
|
||||
Halted,
|
||||
MplsParse,
|
||||
ClpiParse,
|
||||
UdfNotFound {
|
||||
@@ -215,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,
|
||||
@@ -304,6 +308,7 @@ impl std::fmt::Display for Error {
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -80,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.
|
||||
@@ -89,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) {}
|
||||
|
||||
+6
-4
@@ -564,7 +564,9 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
|
||||
}
|
||||
// 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; }
|
||||
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();
|
||||
@@ -732,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;
|
||||
@@ -753,7 +755,7 @@ mod tests {
|
||||
pgc[co + 21] = 0;
|
||||
pgc[co + 22] = 0;
|
||||
pgc[co + 23] = 200; // last sector
|
||||
// Cell 1: sectors 300-400
|
||||
// Cell 1: sectors 300-400
|
||||
let co = 0xEA + 24;
|
||||
pgc[co + 8] = 0;
|
||||
pgc[co + 9] = 0;
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
//! When both exist, language_streams.txt provides structured types while
|
||||
//! menu_base.prop provides stream number → button name mapping.
|
||||
|
||||
use super::{vocab, LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
|
||||
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType, vocab};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
use super::{vocab, LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
|
||||
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType, vocab};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
|
||||
+7
-6
@@ -92,17 +92,18 @@ 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,
|
||||
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
|
||||
};
|
||||
pub use drive::{find_drive, find_drives, Drive, DriveStatus};
|
||||
pub use drive::{Drive, DriveStatus, find_drive, find_drives};
|
||||
pub use error::{Error, Result};
|
||||
pub use event::{Event, EventKind};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
|
||||
pub use decrypt::{decrypt_sectors, DecryptKeys};
|
||||
pub use decrypt::{DecryptKeys, decrypt_sectors};
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
|
||||
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
|
||||
@@ -114,8 +115,8 @@ pub use mux::MkvStream;
|
||||
pub use mux::NetworkStream;
|
||||
pub use mux::NullStream;
|
||||
pub use mux::StdioStream;
|
||||
pub use mux::{input, output, parse_url, InputOptions, StreamUrl};
|
||||
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use sector::SectorReader;
|
||||
pub use sector::{FileSectorReader, SectorReader};
|
||||
pub use speed::DriveSpeed;
|
||||
pub use udf::{read_filesystem, UdfFs};
|
||||
pub use udf::{UdfFs, read_filesystem};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Buffers across PES boundaries so frames that span two PES packets
|
||||
//! are emitted complete, not truncated.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct Ac3Parser {
|
||||
/// Leftover bytes from previous PES (incomplete frame at end).
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Buffers across PES boundaries so frames spanning two PES packets
|
||||
//! are emitted complete.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
|
||||
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
|
||||
|
||||
+8
-19
@@ -7,26 +7,15 @@
|
||||
//! For MKV: codec ID "S_VOBSUB".
|
||||
//! All frames are keyframes (each is a complete bitmap).
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct DvdSubParser {
|
||||
/// Pre-formatted VobSub .idx palette header for codec_private.
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Detects keyframes (IDR slices).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// H.264 NAL unit types we care about.
|
||||
const NAL_SLICE_IDR: u8 = 5;
|
||||
@@ -263,7 +263,7 @@ mod tests {
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x67); // SPS
|
||||
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB, 0xCD]); // profile=0x42, compat=0x00, level=0x1E
|
||||
// PPS: 00 00 01 [68 <payload>]
|
||||
// PPS: 00 00 01 [68 <payload>]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x68); // PPS
|
||||
data.extend_from_slice(&[0xCE, 0x01]);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::h264::{find_start_code, skip_start_code};
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
// HEVC NAL unit types
|
||||
const NAL_VPS: u8 = 32;
|
||||
@@ -122,7 +122,7 @@ impl CodecParser for HevcParser {
|
||||
|
||||
// Minimal HEVCDecoderConfigurationRecord header
|
||||
record.push(1); // configurationVersion
|
||||
// General profile space, tier flag, profile IDC from SPS
|
||||
// General profile space, tier flag, profile IDC from SPS
|
||||
if sps.len() > 3 {
|
||||
record.push(sps[1]); // general_profile_space + general_tier_flag + general_profile_idc
|
||||
} else {
|
||||
@@ -154,7 +154,7 @@ impl CodecParser for HevcParser {
|
||||
record.push(0xFC);
|
||||
// chromaFormat (6 + 2 bits)
|
||||
record.push(0xFC | 1); // 4:2:0
|
||||
// bitDepthLumaMinus8 (5 + 3 bits)
|
||||
// bitDepthLumaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// bitDepthChromaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
@@ -162,7 +162,7 @@ impl CodecParser for HevcParser {
|
||||
record.extend_from_slice(&[0, 0]);
|
||||
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
|
||||
record.push(0x03); // lengthSizeMinusOne = 3 (4 bytes)
|
||||
// numOfArrays
|
||||
// numOfArrays
|
||||
record.push(3); // VPS, SPS, PPS
|
||||
|
||||
// VPS array
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! For MKV: codec ID "A_PCM/INT/BIG" (BD) or "A_PCM/INT/LIT" (DVD).
|
||||
//! All frames are keyframes (uncompressed audio).
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// BD LPCM header size in bytes.
|
||||
const BD_LPCM_HEADER_SIZE: usize = 4;
|
||||
|
||||
+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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! - Sequence extension: 00 00 01 B5
|
||||
//! - Picture header: 00 00 01 00
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame};
|
||||
use super::{CodecParser, Frame, pts_to_ns};
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
/// Sequence header start code suffix.
|
||||
@@ -153,7 +153,7 @@ impl CodecParser for Mpeg2Parser {
|
||||
bits += 64 * 8;
|
||||
}
|
||||
}
|
||||
let total_bytes = 4 + ((bits + 7) / 8) as usize;
|
||||
let total_bytes = 4 + bits.div_ceil(8) as usize;
|
||||
(sc + total_bytes).min(data.len())
|
||||
}
|
||||
}
|
||||
@@ -415,7 +415,7 @@ mod tests {
|
||||
// Sequence extension: 00 00 01 B5 [ext data]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE]);
|
||||
data.extend_from_slice(&[0x14, 0x8A, 0x00, 0x01, 0x00, 0x00]); // ext payload
|
||||
// Picture header follows.
|
||||
// Picture header follows.
|
||||
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
|
||||
data.extend_from_slice(&[0xFF; 4]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Each PES packet contains one or more segments.
|
||||
//! All segments are keyframes (no inter-segment dependencies).
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct PgsParser;
|
||||
|
||||
|
||||
+127
-43
@@ -1,17 +1,24 @@
|
||||
//! Dolby TrueHD / Atmos elementary stream parser.
|
||||
//!
|
||||
//! TrueHD access units are 2560-byte fixed-size units (40 per major sync).
|
||||
//! Each unit starts with a 4-byte header: [length_hi, length_lo, timestamp_hi, timestamp_lo].
|
||||
//! Major sync: 0xF8726FBA appears within a unit.
|
||||
//! Buffers across PES boundaries for complete unit delivery.
|
||||
//! 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};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// TrueHD access unit size (fixed).
|
||||
const TRUEHD_UNIT_SIZE: usize = 2560;
|
||||
/// 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 {
|
||||
@@ -23,9 +30,58 @@ impl Default for TrueHdParser {
|
||||
impl TrueHdParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buf: Vec::with_capacity(TRUEHD_UNIT_SIZE * 4),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for TrueHdParser {
|
||||
@@ -33,45 +89,59 @@ impl CodecParser for TrueHdParser {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
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();
|
||||
|
||||
// BD-TS TrueHD access units:
|
||||
// [0..1] length in 16-bit words (includes the 4-byte header)
|
||||
// [2..3] timestamp (ignored — we use PES PTS)
|
||||
// [4..] MLP payload (raw TrueHD data for MKV)
|
||||
//
|
||||
// MKV stores raw MLP frames without the 4-byte access unit header.
|
||||
const AU_HEADER: usize = 4;
|
||||
loop {
|
||||
if self.buf.len() < 4 {
|
||||
break;
|
||||
}
|
||||
|
||||
while self.buf.len() >= AU_HEADER {
|
||||
let unit_words = ((self.buf[0] as usize) << 8) | self.buf[1] as usize;
|
||||
// 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 {
|
||||
// Padding — skip 2 bytes
|
||||
self.buf.drain(..2);
|
||||
continue;
|
||||
}
|
||||
let unit_bytes = unit_words * 2;
|
||||
if unit_bytes > 65536 || unit_bytes < AU_HEADER {
|
||||
// Invalid — skip 2 bytes to resync
|
||||
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 {
|
||||
// Incomplete unit — wait for more data
|
||||
break;
|
||||
break; // incomplete access unit, wait for more data
|
||||
}
|
||||
|
||||
// Strip the 4-byte access unit header, pass only MLP payload
|
||||
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,
|
||||
keyframe: true,
|
||||
data: self.buf[AU_HEADER..unit_bytes].to_vec(),
|
||||
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
|
||||
@@ -99,15 +169,17 @@ mod tests {
|
||||
fn make_truehd_unit(size_bytes: usize) -> Vec<u8> {
|
||||
let words = size_bytes / 2;
|
||||
let mut data = vec![0u8; size_bytes];
|
||||
// 4-byte header: [length_hi, length_lo, ts_hi, ts_lo]
|
||||
data[0] = (words >> 8) as u8;
|
||||
data[0] = ((words >> 8) & 0x0F) as u8;
|
||||
data[1] = (words & 0xFF) as u8;
|
||||
data[2] = 0; // timestamp
|
||||
data[3] = 0;
|
||||
// Fill payload with non-zero to distinguish from header
|
||||
for b in data[4..].iter_mut() {
|
||||
*b = 0xAA;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -119,15 +191,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_unit_strips_header() {
|
||||
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);
|
||||
// Output should be 200 - 4 = 196 bytes (header stripped)
|
||||
assert_eq!(frames[0].data.len(), 196);
|
||||
assert_eq!(frames[0].data[0], 0xAA); // payload, not header
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -142,19 +212,33 @@ mod tests {
|
||||
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(), 196); // 200 - 4 header
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_units_in_one_pes() {
|
||||
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(), 96); // 100 - 4
|
||||
assert_eq!(frames[1].data.len(), 116); // 120 - 4
|
||||
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]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Frame start = Frame header start code (0x0D).
|
||||
//! I-frames (keyframes) are identified from the frame header.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const SC_SEQUENCE_HEADER: u8 = 0x0F;
|
||||
const SC_ENTRY_POINT: u8 = 0x0E;
|
||||
|
||||
+184
-14
@@ -5,9 +5,93 @@
|
||||
//!
|
||||
//! 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;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -30,9 +114,17 @@ 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,
|
||||
/// When set and the flag is raised, fill_extents returns Err(Halted) at the
|
||||
/// next retry boundary. Unlike skip_errors, this propagates the error up so
|
||||
/// the rip terminates cleanly. Share the Arc with Drive::halt_flag() to get
|
||||
/// unified Stop behavior across drive reads and sector processing.
|
||||
halt: Option<Arc<AtomicBool>>,
|
||||
event_fn: Option<Box<dyn Fn(Event) + Send>>,
|
||||
eof: bool,
|
||||
|
||||
// PES output
|
||||
@@ -69,7 +161,7 @@ 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;
|
||||
@@ -96,8 +188,11 @@ 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,
|
||||
halt: None,
|
||||
event_fn: None,
|
||||
eof: false,
|
||||
ts_demuxer,
|
||||
ps_demuxer,
|
||||
@@ -107,6 +202,33 @@ impl DiscStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
/// Share a halt flag — typically from `Drive::halt_flag()`. When raised,
|
||||
/// the next read-retry boundary inside fill_extents returns Err(Halted)
|
||||
/// instead of continuing. Required for Stop to work during dense bad-sector
|
||||
/// regions (where read() loops internally waiting for enough clean data to
|
||||
/// emit a frame and would otherwise never check an external stop signal).
|
||||
pub fn set_halt(&mut self, flag: Arc<AtomicBool>) {
|
||||
self.halt = Some(flag);
|
||||
}
|
||||
|
||||
fn is_halted(&self) -> bool {
|
||||
self.halt
|
||||
.as_ref()
|
||||
.map(|h| h.load(Ordering::Relaxed))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -125,22 +247,70 @@ impl DiscStream {
|
||||
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);
|
||||
|
||||
self.reader
|
||||
.read_sectors(lba, sectors, &mut self.read_buf[..bytes])?;
|
||||
self.buf_valid = bytes;
|
||||
self.current_offset += sectors as u32;
|
||||
// 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.
|
||||
//
|
||||
// Halt is checked at the top of every iteration — in a dense bad zone
|
||||
// this loop can spend minutes shrinking and skipping sectors; without
|
||||
// the check, Stop wouldn't take effect until the outer PES read() loop
|
||||
// finally emits a frame, which may never happen.
|
||||
loop {
|
||||
if self.is_halted() {
|
||||
return Err(crate::error::Error::Halted.into());
|
||||
}
|
||||
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;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
// Shrink and retry at the same LBA with a smaller batch.
|
||||
if let Some(ev) = self.adaptive.on_failure() {
|
||||
self.emit(ev);
|
||||
}
|
||||
}
|
||||
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
|
||||
+1
-1
@@ -441,7 +441,7 @@ mod tests {
|
||||
fn test_write_uint() {
|
||||
let mut buf = Vec::new();
|
||||
write_uint(&mut buf, 0x4286, 1).unwrap(); // EBML_VERSION = 1
|
||||
// ID: 42 86, Size: 81 (1 byte), Data: 01
|
||||
// ID: 42 86, Size: 81 (1 byte), Data: 01
|
||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x01]);
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -39,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))
|
||||
@@ -71,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);
|
||||
|
||||
|
||||
+1
-1
@@ -103,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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -211,7 +211,7 @@ impl M2tsMeta {
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if let MetaStream::Video {
|
||||
codec_private: Some(ref b64),
|
||||
codec_private: Some(b64),
|
||||
..
|
||||
} = s
|
||||
{
|
||||
|
||||
+44
-36
@@ -5,7 +5,9 @@
|
||||
//! cues and seek head are finalized at the end.
|
||||
|
||||
use super::ebml;
|
||||
use crate::disc::{AudioStream, Chapter, Codec, ColorSpace, HdrFormat, 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).
|
||||
@@ -24,10 +26,10 @@ pub struct MkvTrack {
|
||||
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)
|
||||
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,
|
||||
@@ -52,7 +54,7 @@ impl MkvTrack {
|
||||
};
|
||||
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::Bt709 => (1, 1, 1, 1), // bt709
|
||||
ColorSpace::Unknown => (0, 0, 0, 0),
|
||||
};
|
||||
// Override transfer for non-PQ HDR
|
||||
@@ -95,11 +97,14 @@ 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,
|
||||
@@ -177,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>,
|
||||
@@ -265,7 +260,11 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
|
||||
// DefaultDuration — frame duration in nanoseconds
|
||||
if track.default_duration_ns > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::DEFAULT_DURATION, track.default_duration_ns)?;
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
ebml::DEFAULT_DURATION,
|
||||
track.default_duration_ns,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Video-specific
|
||||
@@ -275,13 +274,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
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)?;
|
||||
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::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)?;
|
||||
@@ -559,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);
|
||||
@@ -570,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!(
|
||||
@@ -583,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();
|
||||
@@ -622,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();
|
||||
|
||||
@@ -637,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();
|
||||
@@ -656,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();
|
||||
@@ -688,7 +699,7 @@ mod tests {
|
||||
// Track VINT: 1 byte (track 1 = 0x81)
|
||||
let track_vint_pos = after_id + size_len;
|
||||
let track_vint_len = 1; // track 1 encoded as 0x81
|
||||
// 2-byte relative timestamp
|
||||
// 2-byte relative timestamp
|
||||
let ts_pos = track_vint_pos + track_vint_len;
|
||||
// flags byte
|
||||
let flags_pos = ts_pos + 2;
|
||||
@@ -730,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
|
||||
@@ -756,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(),
|
||||
@@ -775,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
|
||||
@@ -807,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)
|
||||
@@ -832,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
|
||||
@@ -842,4 +851,3 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Write: PES frames in → MKV mux → Matroska container.
|
||||
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::{ebml, WriteSeek};
|
||||
use super::{WriteSeek, ebml};
|
||||
|
||||
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
|
||||
|
||||
@@ -64,7 +64,7 @@ impl MkvStream {
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
let muxer = MkvMuxer::new_with_chapters(
|
||||
let muxer = MkvMuxer::new(
|
||||
writer,
|
||||
&tracks,
|
||||
Some(&title.playlist),
|
||||
@@ -150,9 +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(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()),
|
||||
}
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ pub use m2ts::M2tsStream;
|
||||
pub use mkvstream::MkvStream;
|
||||
pub use network::NetworkStream;
|
||||
pub use null::NullStream;
|
||||
pub use resolve::{input, output, parse_url, InputOptions, StreamUrl};
|
||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use stdio::StdioStream;
|
||||
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ impl crate::pes::Stream for NetworkStream {
|
||||
match &mut self.mode {
|
||||
Mode::Write {
|
||||
writer,
|
||||
ref mut header_written,
|
||||
header_written,
|
||||
..
|
||||
} => {
|
||||
if !*header_written {
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ impl crate::pes::Stream for StdioStream {
|
||||
}
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.writer {
|
||||
Some(ref mut w) => {
|
||||
Some(w) => {
|
||||
if !self.header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
|
||||
+1
-5
@@ -227,11 +227,7 @@ fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+128
-21
@@ -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 {
|
||||
@@ -179,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() };
|
||||
@@ -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
|
||||
};
|
||||
|
||||
+16
-6
@@ -63,7 +63,8 @@ struct SCSITaskSGElement {
|
||||
|
||||
// ── External IOKit / CoreFoundation functions ───────────────────────────────
|
||||
|
||||
extern "C" {
|
||||
// Rust 2024: FFI blocks declaring extern fns must be `unsafe extern`.
|
||||
unsafe extern "C" {
|
||||
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
|
||||
fn IOBSDNameMatching(
|
||||
master: MachPort,
|
||||
@@ -97,10 +98,18 @@ extern "C" {
|
||||
// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h
|
||||
|
||||
/// Read a function pointer from a COM vtable at the given index.
|
||||
///
|
||||
/// # Safety
|
||||
/// `iface` must be a valid COM interface pointer (*mut *mut c_void), and
|
||||
/// `index` must be a valid vtable slot for the target type `T`.
|
||||
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
|
||||
let vtable = *iface as *const *const std::ffi::c_void;
|
||||
let fn_ptr = *vtable.add(index);
|
||||
std::mem::transmute_copy(&fn_ptr)
|
||||
// Rust 2024: `unsafe fn` bodies are no longer implicitly unsafe.
|
||||
// Each unsafe op needs its own `unsafe { }` block.
|
||||
unsafe {
|
||||
let vtable = *iface as *const *const std::ffi::c_void;
|
||||
let fn_ptr = *vtable.add(index);
|
||||
std::mem::transmute_copy(&fn_ptr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Call Release (vtable index 3) on any COM interface.
|
||||
@@ -455,8 +464,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) };
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ struct SptwbDirect {
|
||||
|
||||
// ── Windows FFI ────────────────────────────────────────────────────────────
|
||||
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn CreateFileW(
|
||||
lpFileName: *const u16,
|
||||
dwDesiredAccess: u32,
|
||||
|
||||
+16
-2
@@ -10,7 +10,15 @@ 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 {
|
||||
@@ -45,7 +53,13 @@ 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;
|
||||
|
||||
+10
-8
@@ -101,7 +101,7 @@ impl UdfFs {
|
||||
None => {
|
||||
return Err(Error::UdfNotFound {
|
||||
path: path.to_string(),
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
let entry = current
|
||||
@@ -136,7 +136,7 @@ impl UdfFs {
|
||||
None => {
|
||||
return Err(Error::UdfNotFound {
|
||||
path: path.to_string(),
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
let entry = current
|
||||
@@ -375,7 +375,7 @@ impl UdfFs {
|
||||
None => {
|
||||
return Err(Error::UdfNotFound {
|
||||
path: path.to_string(),
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
let entry = current
|
||||
@@ -879,6 +879,7 @@ impl BufferedSectorReader<'_> {
|
||||
start_lba + offset,
|
||||
batch,
|
||||
&mut self.cache[buf_off..buf_off + batch as usize * 2048],
|
||||
true,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
@@ -902,7 +903,7 @@ impl BufferedSectorReader<'_> {
|
||||
let bytes = batch as usize * 2048;
|
||||
if self
|
||||
.inner
|
||||
.read_sectors(start + offset, batch, &mut tmp[..bytes])
|
||||
.read_sectors(start + offset, batch, &mut tmp[..bytes], true)
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
@@ -924,6 +925,7 @@ impl SectorReader for BufferedSectorReader<'_> {
|
||||
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)
|
||||
@@ -939,7 +941,7 @@ impl SectorReader for BufferedSectorReader<'_> {
|
||||
}
|
||||
let block = self.batch;
|
||||
self.cache.resize(block as usize * 2048, 0);
|
||||
match self.inner.read_sectors(lba, block, &mut self.cache) {
|
||||
match self.inner.read_sectors(lba, block, &mut self.cache, true) {
|
||||
Ok(_) => {
|
||||
self.cache_start = lba;
|
||||
self.cache_sectors = block as u32;
|
||||
@@ -947,7 +949,7 @@ impl SectorReader for BufferedSectorReader<'_> {
|
||||
Err(_) => {
|
||||
// Near end of disc or error — single sector fallback
|
||||
self.cache.resize(2048, 0);
|
||||
self.inner.read_sectors(lba, 1, &mut self.cache)?;
|
||||
self.inner.read_sectors(lba, 1, &mut self.cache, true)?;
|
||||
self.cache_start = lba;
|
||||
self.cache_sectors = 1;
|
||||
}
|
||||
@@ -956,12 +958,12 @@ impl SectorReader for BufferedSectorReader<'_> {
|
||||
Ok(2048)
|
||||
} else {
|
||||
// Multi-sector read — pass through
|
||||
self.inner.read_sectors(lba, count, buf)
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,8 @@ fn css_is_scrambled_detection() {
|
||||
/// then decrypt with decrypt_unit() and verify the plaintext matches.
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_roundtrip() {
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
let unit_key = [0xAAu8; 16];
|
||||
let aacs_iv: [u8; 16] = [
|
||||
@@ -207,8 +207,8 @@ fn aacs_disc_hash_deterministic() {
|
||||
/// decrypt_unit_key recovers the original.
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_key_roundtrip() {
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
let vuk = [
|
||||
0x11u8, 0x14, 0x36, 0x0B, 0x10, 0xEE, 0x6E, 0xAC, 0x78, 0xAA, 0x4A, 0xC0, 0xB7, 0x52, 0xEA,
|
||||
@@ -309,8 +309,8 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
|
||||
/// Independent AES-128-ECB encrypt (uses `aes` crate directly, NOT our library).
|
||||
fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.encrypt_block(&mut block);
|
||||
@@ -321,8 +321,8 @@ fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
|
||||
/// Independent AES-128-CBC encrypt (uses `aes` crate directly, NOT our library).
|
||||
fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut prev = *iv;
|
||||
let num_blocks = data.len() / 16;
|
||||
@@ -703,7 +703,7 @@ fn aacs_parse_unit_key_ro_minimal() {
|
||||
data[0..4].copy_from_slice(&uk_pos.to_be_bytes());
|
||||
// app_type
|
||||
data[16] = 1; // BD-ROM
|
||||
// num_bdmv_dir
|
||||
// num_bdmv_dir
|
||||
data[17] = 1;
|
||||
// flags
|
||||
data[18] = 0;
|
||||
|
||||
+8
-2
@@ -1,7 +1,7 @@
|
||||
//! Disc scanning pipeline tests.
|
||||
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::SectorReader;
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::{Disc, DiscTitle, ScanOptions};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -21,7 +21,13 @@ 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;
|
||||
|
||||
+12
-6
@@ -1,7 +1,7 @@
|
||||
//! UDF parser tests using a MockSectorReader.
|
||||
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::{read_filesystem, SectorReader};
|
||||
use libfreemkv::{SectorReader, read_filesystem};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
@@ -39,7 +39,13 @@ 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 +221,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 +229,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 +249,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 +469,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