Compare commits

...
51 Commits
Author SHA1 Message Date
MattJackson e3cfd27942 v0.20.7: version bump for unified release (no source changes)
The 0.20.7 work lives downstream in autorip — process-level safety net
(hard watchdog 5-min mux escalation + std::process::exit, restart-loop
counter + auto-quarantine after 3 attempts, partial-state preservation,
failure_reason surfaced in /api/state).

Bumping libfreemkv to 0.20.7 keeps the 4 crates on the unified release
train per project docs.
2026-05-13 14:29:40 -07:00
MattJackson aa12bdad62 v0.20.6: io::bounded — halt-safe wrapper for blocking syscalls
Generalizes 0.20.5's hand-written wait_after_with_timeout into a
reusable primitive. After this change, every blocking syscall in the
recovery + mux paths is wrapped, so cooperative Halt has bounded
~250 ms latency reach even into kernel-owned thread states.

New module src/io/bounded.rs:
- BoundedError { Halted, Timeout, WorkerLost }
- bounded_syscall<F, R>(halt: Option<&Halt>, timeout, op) -> Result<R, BoundedError>
- Worker thread runs op; main thread recv_timeouts on a rendezvous
  channel in 250 ms slices, polling halt between slices.
- Worker is intentionally leaked on timeout/halt — kernel reaps when
  the syscall finally returns or at process exit. Calling thread is
  NEVER trapped inside a kernel call.
- 6 unit tests cover the happy path + each error variant.

Refactored callsites:
- src/io/writeback/linux.rs::wait_after_with_timeout now delegates
  to bounded_syscall. ~30 LOC of duplicated channel/thread plumbing
  deleted. Same semantics, cleaner.
- src/io/writeback_file.rs::WritebackFile::sync_all now wraps the
  final libc::fsync(fd) with bounded_syscall (60 s deadline). On
  timeout: log error at target=mux and return Ok — kernel will flush
  on close, best-effort but bounded. Covers FileSectorSink::finish,
  PatchSink::close, SweepSink::close, and the mux MKV finalize path
  (they all sync through WritebackFile).

What still hangs (deliberately not wrapped — too hot a path):
- File::write itself. Per-frame write on a wedged NFS could still
  block; but back-pressure from a stuck consumer means the producer
  notices within seconds, not minutes — different failure mode than
  the WAIT_AFTER hang 0.20.5/0.20.6 fix.
2026-05-13 14:22:24 -07:00
MattJackson ef3895cdc5 v0.20.5: NFS-aware writeback + bounded sync_file_range timeout
Targets the recurring mux hang on NFS dest where the consumer thread
sits indefinitely inside libc::sync_file_range(SYNC_FILE_RANGE_WAIT_AFTER)
because the NFS server never returns a commit ack. The whole rip
wedges; halt is cooperative and can't reach inside a kernel syscall.

A. NFS detection at WritebackPipeline construction (fstatfs f_type ==
NFS_SUPER_MAGIC 0x6969). When NFS:
- Skip SYNC_FILE_RANGE_WAIT_AFTER entirely.
- Skip posix_fadvise(DONTNEED) — NFS client handles its own buffering.
- Still issue async SYNC_FILE_RANGE_WRITE (harmless hint).
Cannot hang on a syscall not made. fstatfs failure fails open (assume
local). Logged at info on construction so operators see which strategy
is active. The whole hang vector is removed for NFS deployments.

B. Hard timeout on WAIT_AFTER for non-NFS (defense in depth, since
even a degraded local disk could in principle hang the syscall).
Each WAIT_AFTER runs on a worker thread; main thread waits on a
sync_channel rendezvous with 30s deadline. On timeout: log error,
set per-pipeline 'degraded' Arc<AtomicBool>, downgrade to NFS-style
skip for the rest of the pipeline's life. Worker thread leaks
intentionally — it'll unwind when the syscall eventually returns or
the process exits. Converts indefinite freeze into 'log loud +
downgrade + keep ripping'.

C. Diagnostic logging for the 73%-of-this-movie reproduction:
- WritebackFile::seek logs every non-trivial seek (from, to, signed
  delta) at target=mux so we can see if MkvMuxer seeks back before
  a stall.
- WritebackPipeline::finalize logs the chunk being finalised before
  any WAIT_AFTER call, so a hung chunk is identifiable by offset.

No new dependencies. macOS / Windows noop stubs unchanged. Net
+198 LOC libfreemkv (mostly writeback/linux.rs).
2026-05-13 14:09:55 -07:00
MattJackson 2dcf969ac8 v0.20.4: mux performance + observability — universal across storage
Four targeted changes to maximize mux throughput regardless of storage
backend (local SSD, local HDD, NFS, network share) and surface enough
log data to diagnose 'mux slow' reports without a re-rip:

1. POSIX_FADV_SEQUENTIAL on FileSectorSource::open (Linux only).
   Widens the kernel readahead window for sequential ISO reads. One
   syscall at open, free on every storage type.

2. POSIX_FADV_DONTNEED on the ISO read side after every 32 MiB chunk.
   Mirrors the writeback DONTNEED that already runs on the write
   side. Keeps the read-side page cache bounded during multi-GB ISO
   reads — eliminates the OOM-pressure / eviction-storm risk on
   long mux runs. Linux only; per-drop trace at target="mux".

3. WritebackFile::create_with_size_hint(path, size_bytes) calls
   fallocate(FALLOC_FL_KEEP_SIZE) on Linux to pre-reserve extents
   for the output. Reported file size stays 0 (writes grow it
   naturally) but the on-disk extent allocation is contiguous —
   reduces extent fragmentation for big sequential muxes. Wired
   into mkv:// and m2ts:// output paths via DiscTitle::size_bytes.
   No-op on macOS/Windows; old create() kept with #[allow(dead_code)]
   for callers without a size hint.

4. Adaptive WRITEBACK_CHUNK_BYTES in the Linux writeback pipeline.
   Tracks sync_file_range(WAIT_AFTER) elapsed_ms in a rolling
   16-sample window. p95 > 200 ms → double chunk size (cap 256 MiB).
   p95 < 20 ms → halve (floor 4 MiB). One algorithm, both
   fast-storage (small chunks, responsive) and slow-storage (big
   chunks, fewer commit round-trips) optimized. Per-chunk trace +
   per-32-chunk debug snapshot + info-on-resize so an operator can
   see where the autoscaler settled.

All four are universal — no storage-type detection, no env vars to
flip, no per-deploy tuning required. Total +201/-6 across four files.
2026-05-13 13:50:44 -07:00
MattJackson 005f887bf9 v0.20.3: add halt check to Disc::patch backtrack inner loop
WO-5 (partial): the patch backtrack inner loop ('while bt_pos <
backtrack_end' in disc/patch.rs) issues per-sector reads to fill the
gap created by a damage-window skip. A long backtrack span can run
minutes; without an inline halt poll, the outer halt only takes
effect when control returns to the per-range loop. Adds a halt poll
at the top of each iteration so cancellation propagates inside the
backtrack span.

Per-sector read failures inside the backtrack already drop through
to the main fail path; this only changes the cancellation latency
between an /api/stop call and the producer actually unwinding. Drops
worst-case unwind from 'whole backtrack span × per-sector recovery
timeout' (minutes) to 'one in-flight SCSI command' (seconds).

The broader Arc<AtomicBool> → Halt migration on CopyOptions /
SweepOptions / PatchOptions / Drive::halt and Pipeline::send halt-
awareness is deferred — separate cycle, larger API impact.
2026-05-13 11:55:52 -07:00
MattJackson d7243a6044 v0.20.2: delete dead retry block + wedge-family cooldown in Disc::patch
WO-3c: Delete dead non-NOT_READY retry block (~100 LOC). The block
declared retry_count = 0 inside the per-iteration Err arm, so the
'MAX_NON_NOT_READY_RETRIES=3' budget actually fired exactly once
(1s pause + 1 retry) before falling through to NonTrimmed. The
'exponential backoff: 2s, 4s, 8s' comment was wrong by construction.
Cross-pass NonTrimmed retry (each pass gives the same sectors another
shot) already covers the recovery case, and gives the drive minutes
between attempts instead of 1-8 seconds — empirically what stochastic
recovery on the BU40N actually needs.

WO-4 (targeted slice): Add wedge-family cooldown on HARDWARE_ERROR /
ILLEGAL_REQUEST senses. These are what the BU40N's firmware fast-fail
state returns; every subsequent read in that state comes back in
<100ms. Pre-fix patch hammered the drive: mark NonTrimmed, sleep 1s,
advance, hit next wedge, mark, sleep 1s — exactly the rapid-retry
cadence the firmware is sensitive to. Now a wedge-family sense triggers
WEDGE_FAMILY_COOLDOWN_SECS=30 cooldown (matches read_error.rs's
ZONE_ENTRY_COOLDOWN_SECS), and WEDGE_ABORT_THRESHOLD=16 consecutive
wedges aborts the pass for autorip eject+reload. Any non-wedge read
clears the counter.

Also drops the duplicate NonTrimmed dispatch (Mapfile::record is
idempotent so it wasn't a correctness bug, but it doubled per-failure
consumer work).
2026-05-13 11:53:57 -07:00
MattJackson f1926c38dc v0.20.1: delete SectorReader, extract Disc::patch, doc/stub cleanup
WO-2 (delete SectorReader trait):
- The 0.18 trait split into SectorSource (read-only) and SectorSink
  (write-only) is final; the legacy SectorReader alias was a bridge.
- Renames every internal &mut dyn SectorReader (~25 sites) to
  &mut dyn SectorSource. The trait method capacity() becomes
  capacity_sectors() with a default of 0 (preserves SectorReader's
  default-0 behavior).
- Deletes the SectorReader trait, its blanket-to-Source bridge, and
  the FileSectorReader type alias. Adds explicit forwarding impls
  for Box<dyn SectorSource> and &mut dyn SectorSource so generic
  decorators like DecryptingSectorSource<S: SectorSource> compose.

WO-3a (extract Disc::patch):
- Moves Disc::patch (1230 lines) and bytes_bad_in_title from
  disc/mod.rs into disc/patch.rs as a split inherent impl. Zero
  behavior change — pure mechanical relocation. disc/mod.rs drops
  from 3,945 to 2,714 LOC.

WO-6 (partial):
- Deletes src/labels/png_filenames.rs — was a 72-LOC stub with
  detect() returning false, never wired into the PARSERS registry.

project docs doc drift fixes (audited 2026-05-13):
- JUMP_BASE_SECTORS: 256→1024 (64 MB base for UHD, not 8 MB)
- PASSN_DAMAGE_THRESHOLD_PCT: 12→6
- PASSN_SKIP_SECTORS_BASE: 64→32
- MAX_RANGE_SECS=180: replaced by proportional range_sectors × 25,
  capped at RANGE_BUDGET_CAP_SECS=1800.
2026-05-13 11:36:55 -07:00
MattJackson 4709a73c80 v0.20.0: delete FrameSource/FrameSink, keep single Stream trait
The 0.18 trait split into FrameSource (read-only) and FrameSink
(write-only) was an over-engineered API. Consumers don't think
"frame source backed by MKV" — they think "open MKV for reading".
The split paid a real API-complexity cost (two trait names, two
re-exports, dual impls per bidirectional type, deprecation bridge)
for one marginal property: compile-time direction-safety at the
trait-object boundary. The runtime error path on a wrong-direction
call (StreamReadOnly / StreamWriteOnly) is unambiguous and rare in
practice.

Deletions:
- pes::Stream is no longer #[deprecated]
- pes::FrameSource trait + its blanket-from-Stream bridge
- pes::FrameSink trait + the trampoline impls on every concrete type
- The compile-time-direction-safety test scaffolding
- Crate-root FrameSource / FrameSink re-exports

Additions:
- Stream is now Send-bounded (Stream: Send supertrait). Every
  concrete impl was already Send-compliant — Box<dyn Read + Send>
  and Box<dyn Write + Send> were already in place on the trait
  objects MkvStream / M2tsStream / etc hold internally. Promoting
  Send into the trait makes Box<dyn Stream> Send too, which lets
  autorip drop its SendStream unsafe newtype.

The public API is now: one Stream trait, one concrete type per
format, two constructors (open/create or input/output). Bidirectional
types route through internal Mode { Read | Write } discriminants.

Net: -347 lines libfreemkv, -38 lines autorip, -5 lines freemkv.
2026-05-13 08:42:14 -07:00
MattJackson 55bd1ee868 v0.19.1: repair Cargo.toml after botched 0.19.0 bump
v0.19.0 was tagged with a search-and-replace gone wrong:
rust-version, serde, and zip all had their version strings
replaced with "0.19.0". Edition 2024 rejected rust-version
0.19.0 (< 1.85), failing every CI build. No artifacts shipped
to crates.io.

Repair:
- rust-version: 0.19.0 → 1.86 (CI pin)
- serde: 0.19.0 → 1
- zip: 0.19.0 → 2

Also drops an unused start_lba binding in mux/disc.rs that
clippy 1.86 catches.
2026-05-12 21:29:07 -07:00
MattJackson 5285e8b6ec v0.19.0: bump version 2026-05-12 19:24:06 -07:00
MattJackson c5af61e46b v0.18.25: bump version 2026-05-12 16:27:28 -07:00
MattJackson ed73c1ab68 v0.18.25: add mux debug logging for reader type and stall detection 2026-05-12 16:27:16 -07:00
MattJackson 5ce2df0888 v0.18.24: bump version 2026-05-12 15:46:47 -07:00
MattJackson ea591a36a5 v0.18.23: bump version 2026-05-12 10:59:45 -07:00
MattJackson 368e10486e v0.18.22: bump version 2026-05-12 09:33:00 -07:00
MattJackson a99c4f8487 v0.18.21: format code 2026-05-12 09:18:52 -07:00
MattJackson 4df470e572 v0.18.21: fix borrow checker, make io public 2026-05-12 09:01:52 -07:00
MattJackson a0b13941fe v0.18.21: fix borrow checker error in debug logging 2026-05-12 08:49:33 -07:00
MattJackson 275c9da009 v0.18.21: verbose debug logging for pipeline stalls (fixed) 2026-05-12 08:37:42 -07:00
MattJackson da183722fa v0.18.20: bump version 2026-05-11 22:33:15 -07:00
MattJackson 4a5424eabe v0.18.20: bump version 2026-05-11 22:28:57 -07:00
MattJackson 293d89c586 v0.18.20: separate read/write pipeline depths 2026-05-11 22:18:56 -07:00
MattJackson 4840f5134d libfreemkv: increase DEFAULT_PIPELINE_DEPTH to 32 for smoother mux speed 2026-05-11 22:01:27 -07:00
MattJackson 11c8211605 v0.18.18: bump version 2026-05-11 20:16:30 -07:00
MattJackson 1038beaf06 v0.18.17: bump version 2026-05-11 19:58:36 -07:00
MattJackson f1df57196c libfreemkv: add Clone derive to MuxAtomics 2026-05-11 19:54:50 -07:00
MattJackson 8ba9dc1c0b v0.18.16: bump version 2026-05-11 15:57:03 -07:00
MattJackson 49b131f39a add debug logging for MKB processing 2026-05-11 15:53:11 -07:00
MattJackson 8238ec4ce7 optical vs block batch sizing 2026-05-11 15:35:45 -07:00
MattJackson 4fd5d27df8 v0.18.15: bump version 2026-05-11 11:30:19 -07:00
MattJackson 1fbe272832 disc/scan: surface AACS resolution error on Disc.aacs_error
scan_with() collapsed every failure path from resolve_encryption() into
None via .ok(), so callers couldn't tell the difference between "no
KEYDB found", "KEYDB failed to parse", "disc hash not in KEYDB and
fallback derivation failed", "AACS files unreadable on disc", and a
handshake that rejected every host cert. autorip's UI was stuck
printing "no decryption keys found (check KEYDB)" for all of them,
which is a particularly bad message when the user has actually loaded
a KEYDB and the real failure is something else.

Changes:
- New pub field Disc.aacs_error: Option<Error>. Populated by scan_with
  whenever encrypted && aacs.is_none(). Sentinel KeydbLoad path
  "<no keydb in search paths>" distinguishes the no-keydb case from
  a real load failure without adding a new Error variant (which would
  be a breaking change for downstream exhaustive matches).
- tracing::warn in scan_with at scan_aacs_resolve_failed and
  scan_aacs_no_keydb, with error_code and keydb path for grepping.
- tracing in do_handshake: keydb load failure, host-cert exhaustion
  (with cert count and last error code), VID read failure post-auth,
  and a debug-level success log. Lets us see whether handshake even
  got off the ground for a given disc.

Test fixtures updated to set aacs_error: None.
2026-05-11 11:29:48 -07:00
MattJackson 6bd635725e v0.18.14: bump version 2026-05-11 08:27:10 -07:00
MattJackson 09b77b4dea disc/patch: relabel "possible wedge" heuristic log to avoid confusion
The 'All probes failed — possible wedge condition' log fired during patch
probing whenever 10+ consecutive failures hit AND a probe sweep at the
local zone returned 0 successes. This was distinct from the read_error.rs
'wedge_transition' log that fires when the SCSI sense family ACTUALLY
flips into Hardware/IllegalRequest fast-fail mode.

Two logs both saying 'wedge' caused operator confusion during the
2026-05-11 Dune Pt 2 wedge investigation — was the drive wedged, or was
it just a zone of fully-bad sectors? They mean different things.

Relabel to 'patch_zone_fully_bad' with explicit pointer to read_error.rs
for the canonical wedge detection. Same triggering condition; just clearer
wording in the log stream.
2026-05-10 22:20:44 -07:00
MattJackson 688058b3e8 labels/bdmt: drop description fields that are just XML child elements
Disc-04 (Top Gun: Maverick) re-test 2026-05-11 surfaced a real-world
bdmt_eng.xml where <di:description> contained no prose, only nested
<di:thumbnail href="…"/> elements. The previous parser surfaced
the raw XML fragment as the description string ("<di:thumbnail
href=\"tgm_meta_sm.jpg\" />\\r\\n      <di:thumbnail
href=\"tgm_meta_lg.jpg\" />"). Worse than no description.

Fix: filter description candidates that begin with `<` after
trimming. Real prose never starts with an angle bracket; XML-only
content always does. Net: title extraction unaffected (it uses its
own element-priority path); description field drops when it would
otherwise carry XML noise.

Two new bdmt tests, 12 of 12 passing.
2026-05-10 22:12:59 -07:00
MattJackson c96bac7977 labels: append CLPI orphan streams after gap-fill
Three layered sources of stream labels now, in precedence order:
1. **Framework parser** (paramount/criterion/pixelogic/ctrm/dbp/deluxe)
   — editorial labels with purpose/qualifier ("English Atmos",
   "Director's Commentary", "English SDH"). High or Medium confidence.
2. **MPLS gap-fill** (`fill_gaps_from_mpls`) — every stream the
   playlist references gets at least a basic lang+codec label, even
   when the framework parser missed it.
3. **CLPI orphan append** (`append_clpi_orphans`) — streams in
   /BDMV/CLIPINF/*.clpi ProgramInfo that NO MPLS playlist references.
   Empirical (2026-05-11): ~5% of streams across the 11-disc corpus,
   most dramatic on disc-02 (HDMV-only) at 40% CLPI-only.

Orphan numbering: each appended orphan gets
`stream_number = max(existing per type) + N` so playlist-reachable
streams keep their original positions and orphans sort cleanly at
the tail.

Orphan dedup: (stream_type, language, codec_hint) tuple — fuzzier
than PID matching (PIDs aren't carried on StreamLabel) but it's the
only signal available downstream of the gap-fill. False positives
(genuine orphan that happens to share lang+codec with an existing
entry) silently drop, which is the conservative failure mode — the
user-facing display would just see a confusing duplicate otherwise.

`mpls_universal::language_display_name` and `::codec_name` promoted
from private fn to pub(crate) so this module can build orphan labels
with consistent naming.

Tests: 2 new in gap_fill_tests — synthetic-input verification of the
dedup tuple logic and the stream_number assignment. 6/6 tests in the
gap-fill module now passing.
2026-05-10 22:08:14 -07:00
MattJackson c32acff3e4 disc/read_error: rustfmt fixup for wedge-prevention commit 2026-05-10 22:00:08 -07:00
MattJackson b58e2d9873 disc/read_error: wedge PREVENTION — jump on first error + 30s cooldown
Rewrites the Pass 1 wedge handling from "slow skip after the drive
has already wedged" to "prevent the wedge transition in the first
place." Driven by 2026-05-11 empirical data: the BU40N transitioned
into IllegalRequest fast-fail mode at exactly 7 medium errors in
6.5 seconds (~1 read/sec retry cadence). Once there, only physical
eject + reload clears it — 30s pauses + 1 GB jumps do not.

The fix is the user's mental model from that session:

  "We can detect bad reads, failed reads, and asking to read again
   fast after causes a wedge. We need to prevent the wedge in the
   first place."

Two changes to the centralized error handler:

1. **`for_sweep().fast_jump_threshold = 1`** (was 4). Pass 1 now
   JumpAheads on the FIRST outer-batch failure, not the 4th. The
   drive never gets back-to-back retries at the same LBA in Pass 1
   — every error → jump 64 MB forward + long cooldown. Pass N keeps
   `fast_jump_threshold = u64::MAX` because retries on already-known-
   bad LBAs are its whole job.

2. **`ZONE_ENTRY_COOLDOWN_SECS = 30`**. The FIRST error after a
   clean run (when `consecutive_outer_failures == 1` and we're not
   bisecting) uses this long pause instead of the standard 5 s
   FAIL_PAUSE_SECS. Gives the BU40N's firmware / bridge internal
   retry counters 30 s of breathing room before the next read,
   preventing the "7 errors in 6.5 s" cascade. Subsequent errors
   in the same zone use the standard 5 s pause (we've already
   jumped past the initial damage; further errors mean we landed
   in another bad cluster).

Pass N exempt from the zone-entry cooldown — `bisect_on_marginal=
true` skips the long-pause arm. Pass N's per-sector retries on
known-bad LBAs would multiply uselessly with 30 s/error.

Test updates: 4 tests' expected behavior changed under the new
policy. Renamed `pass_1_marginal_skips_instead_of_bisecting` →
`pass_1_marginal_jumps_immediately_not_bisecting`. Renamed
`pass_1_jumps_after_4_consecutive_outer_failures` →
`pass_1_jumps_immediately_on_first_outer_failure`. Updated
`both_passes_pause_on_failed_read_for_wedge_avoidance` (now
`pass_1_zone_entry_uses_long_cooldown` + `pass_n_pauses_uniformly_on_failed_read`).

Cost analysis:
- Clean disc (no errors): unchanged. 0% overhead.
- Lightly damaged (1-2 zones): +30 s per zone = ~1 min total. Fine.
- Heavily damaged (10+ zones): +5+ min total. The trade for never
  wedging the drive and getting a usable Pass N afterwards.

Expected behavior on the next damaged-disc rip:
- Pass 1 hits damage at LBA X → jumps 64 MB forward immediately,
  pauses 30 s
- Drive's firmware never accumulates the retry pressure that triggers
  IllegalRequest fast-fail
- bytes_maybe accumulates faster (we skip more), but Pass N picks up
  the slack with proper per-sector recovery — and Pass N can actually
  RUN because the drive isn't wedged
2026-05-10 21:56:27 -07:00
MattJackson a9e802c1c2 clpi+labels: extract program_info stream table + CLPI vs MPLS audit
Two layered changes, in service of the empirical question "is CLPI
truly redundant with MPLS for label data?":

1. **clpi.rs ProgramInfo parser**. The existing CLPI parser only
   walked the EP map (for sector-range lookups). Added a parser for
   the ProgramInfo section's per-stream stream_coding_info table:
   pid, coding_type, audio_format/rate, video_format/rate, ISO 639-2
   language. Spec layout per libbluray clpi_parse.c. Best-effort —
   malformed program_info leaves `streams: vec![]`, EP map keeps
   working. `ClipInfo` gains a `streams: Vec<ClpiStream>` field.

2. **labels/clpi_audit.rs**. Diagnostic that walks both
   `/BDMV/CLIPINF/*.clpi` (via the new program_info parser) and
   `/BDMV/PLAYLIST/*.mpls`, builds a (PID → fields) merged view, and
   classifies each row:
   - `Match`: both sources agree (same coding_type + language)
   - `ClpiOnly`: PID in CLPI but no MPLS playlist references it
     (orphan stream on disc — reachable via low-level access, not via menu)
   - `MplsOnly`: PID in MPLS but no CLPI lists it (would indicate a
     parser bug; verified empirically that this NEVER happens)
   - `Divergent`: same PID, different coding_type or language between
     sources (playlist re-tagged or attribute encoding mismatch)
   Surfaced via `labels-analyze` as `clpi_vs_mpls_audit: {matches,
   clpi_only, mpls_only, divergent, total_pids}`. Doesn't affect the
   label output — pure diagnostic.

Empirical findings on the 11-disc corpus (excl. disc-04 truncated):
- 226 matches / 0 mpls_only / 8 clpi_only / 5 divergent across 239 PIDs
- 6 of 10 non-truncated discs have CLPI-only streams (orphans)
- disc-02 (HDMV-only) is the most dramatic: 40% of its 5 streams are
  CLPI-only — MPLS sees 3, CLPI sees 5
- Conclusion: CLPI is NOT truly redundant. ~5% of streams disc-wide
  are CLPI-exclusive. Future work: layer CLPI as a tertiary source
  below MPLS in the labels pipeline (orphan streams marked with even
  lower confidence than MPLS).
2026-05-10 21:50:39 -07:00
MattJackson a876ce846b labels: surface MPLS chapter summary in LabelAnalysis
LabelAnalysis gains `chapter_summary: Vec<ChapterSummary>` — one row
per .mpls file in /BDMV/PLAYLIST/, with chapter count (PlaylistMark
entries with mark_type ≤ 1) and approximate playlist duration in
seconds. Sorted by playlist filename.

Sourced from the existing crate::mpls parser (no new format work).
Useful for identifying the main feature playlist at a glance — it's
the one with the longest duration. Verified on disc-11 (Dune Pt 2):
00800.mpls correctly identified as 2h 45m 49s with 18 chapters
amid 30+ shorter playlists.

Doesn't touch the per-title `disc::DiscTitle::chapters` field which
disc::bluray.rs already populates from the same marks during disc
init — this is purely the diagnostic surface for labels-analyze.
2026-05-10 21:32:54 -07:00
MattJackson 058fd8396f labels: gap-fill MPLS streams when framework parser under-yields
When a framework parser (paramount, criterion, pixelogic, ctrm, dbp,
deluxe) is chosen but its label list covers only a subset of the
stream slots MPLS knows about, merge MPLS-derived entries for the
uncovered (stream_type, stream_number) slots. Framework labels keep
their richer fields (purpose=Commentary, codec_hint with "Atmos",
qualifier=Sdh); MPLS only fills slots the framework left unnamed.

Implementation:
- `fn fill_gaps_from_mpls` walks the MPLS label list, pushing any
  entry whose (type, number) tuple isn't already in the framework
  output. Stable sort by (type, number) groups audios before
  subtitles in the merged result.
- Called from both `extract()` and `analyze()`. Skipped when the
  chosen parser is itself `mpls_universal` (no gaps possible).
- `LabelAnalysis::gap_fill_added` field reports how many slots got
  filled — useful diagnostic from `labels-analyze`.
- `StreamLabelType` gains `Eq + Hash` so the dedup HashSet works.

Tested via 4 new unit tests (155 of 155 labels tests passing, was
151). End-to-end on partial-yield corpus discs:
- disc-05 (Oppenheimer): pixelogic 4/5 already covered, gap_fill_added=0
- disc-11 (Dune Pt 2):   pixelogic 8/11 already covered, gap_fill_added=0

(Real-world gap-fill activations are rare in the current corpus because
pixelogic already incorporates MPLS-equivalent data when matching;
the merge is defensive for less-thorough frameworks.)
2026-05-10 21:28:01 -07:00
MattJackson 5ee28c08b9 labels/mpls_universal: dense stream numbering across playlists
Per-playlist stream_number counters were resetting between MPLS
files, so a disc with 2 MPLS files each listing the same 8 audio
streams produced labels with stream_number 1..8 then 1..8 again
(dedup kept whichever PID was different, leaving the numbering
visibly broken — multiple "audio1: eng" rows).

Move the counters outside the per-file loop and increment only
when an entry survives dedup. Surviving entries now get dense
1..N numbering across the whole disc per stream_type.

Verified on corpus disc-02 (HDMV-only): was `audio1, audio1, audio1`
for the 3 distinct audio codecs (TrueHD/AC-3/DTS-HD MA), now
`audio1, audio2, audio3`. Same fix applies to disc-01 (12 audio
streams across multiple MPLS) and disc-09 (14 audio streams).
2026-05-10 20:58:33 -07:00
MattJackson 764230b1eb labels: universal MPLS fallback + bdmt disc metadata + png stub
Three new modules in the labels platform, all layered so framework-
specific parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe)
always take precedence over the universal layer.

**mpls_universal.rs** (~600 LOC, 9 tests): consumes the already-parsed
`crate::mpls::Playlist::streams` and emits StreamLabel entries with
language + codec_hint per stream. Returns `Confidence::Low` (new
variant) so framework parsers' Medium/High always win the registry's
max-by-confidence tiebreaker; MPLS only gets picked when no framework
matched. Closes the "no BD-J disc" case (HDMV-only navigation) that
previously produced zero labels — language and base codec are
spec-mandated in MPLS STN tables on every Blu-ray ever made.

**bdmt.rs** (~350 LOC, 10 tests): reads `/BDMV/META/DL/bdmt_<lang>.xml`
files into a new `DiscMetadata` struct (localized title names per
ISO 639-2 code, descriptions, optional box-set position). Runs
independently of the parser registry — disc-level metadata, not
per-stream, so the registry's confidence selection doesn't apply.
Surfaced on a new `LabelAnalysis::disc_metadata` field.

**png_filenames.rs** (noop stub): pattern documentation + dead-code
detect/parse for future reactivation. Deferred because MPLS already
delivers per-stream lang/codec/type on every disc; PNG filename
language tokens only add studio variant disambiguation (FRC vs FRP,
LAS vs CSP) — niche enough to not justify the implementation cost
right now.

Wiring changes in `mod.rs`:
- New `Confidence::Low` variant (PartialOrd places it below Medium/High)
- New `ParseResult::low()` constructor
- `mpls_universal` appended last to `PARSERS` registry
- `LabelAnalysis::disc_metadata: Option<DiscMetadata>` field
- `analyze()` runs `bdmt::parse` independently and surfaces result
- `pub use bdmt::DiscMetadata` re-export so the labels-analyze tool
  in freemkv-tools can construct the JSON payload

Total: 151 of 151 labels tests passing (was 132 — added 19 new).
2026-05-10 20:53:56 -07:00
MattJackson 7fb2e07aed v0.18.13: bump version 2026-05-10 19:57:44 -07:00
MattJackson 376aadb335 disc/patch: adaptive batching — 32 sectors, drop to 1 on failure
Pass N now reads at 32 sectors per attempt and drops to 1 only on
batch-read failure to probe each sector individually. After 16
consecutive clean single-sector reads it climbs back to 32. Net
effect: NonTrimmed regions walk ~32x faster in clean stretches
without sacrificing per-sector recovery quality — the drop-to-1
retry from the same cursor position guarantees every sector in a
failed batch is individually attempted.

Design contract:
- A batch-read failure (count > 1) is NOT a recorded failure: no
  NonTrimmed mark, no consecutive_failures bump, no damage_window
  push, cursor stays put. We just drop current_batch to 1 and the
  loop re-attempts the same position at single-sector granularity.
- A single-sector failure (count == 1) follows the existing path:
  NonTrimmed mark, consecutive_failures++, damage_window.push(false),
  post-failure pause, wedge probes.
- Backtrack always at count=1: this path fills a gap that the main
  loop's damage-window skip jumped over. Using batched reads there
  would lump good sectors into NonTrimmed marks when the gap
  contains even one bad sector.

State machine adds:
- `initial_batch` (from opts.block_sectors, default 32 in patch_internal)
- `current_batch` (mutable, starts at initial_batch, drops to 1 on
  batch failure)
- `consecutive_singles_ok` (counter, resets on upscale + failure)
- `ADAPTIVE_UPSCALE_THRESHOLD = 16` (matches sweep's pattern for
  "16 consecutive good = back to fast mode")

Tests:
- pass_n_size_aware_skip.rs PatternedSectorReader now fills each
  sector with its OWN LBA byte (not the starting LBA's byte). This
  matches real drive behavior — the pre-0.18.13 fixture's
  "fill whole batch with one byte" was a shortcut that only worked
  when patch read 1 sector at a time. Existing recovery-quality
  assertions all still pass under adaptive batching.

User spec: "try 32, pass, great, fail -> do 1 sector"
2026-05-10 19:57:27 -07:00
MattJackson 586495b2ee v0.18.12: bump version 2026-05-10 19:44:57 -07:00
MattJackson 72f2224efe disc/patch: leave failed reads NonTrimmed mid-multipass, not Unreadable
User design call after watching Pass 2 mark ~20 KB as "Cosmetic"
(permanently Unreadable) after just 10 retries within a single pass:
"i think it's good or maybe until all passes are done. then it's
gone."

That contradicts what the multi-pass design promises a user. The
project goal in project docs is "recover 100% of readable data from any
optical disc, automatically." Marking sectors Unreadable after a
SINGLE pass's per-range retry budget gives up on sectors that
subsequent passes might recover — drive reads are stochastic, the
sector that fails 10 times in Pass 2 may succeed on attempt 1 in
Pass 3 after temperature / bus state / prior-read patterns shift.
The patch.rs doc comment already noted ~36% of patch-marked
Unreadable sectors turned out to be readable in re-rip experiments.

Three sites in `Disc::patch` were emitting `PatchItem::Unreadable`
mid-pass:
  - backtrack hit damage (line ~2659)
  - all-retries-exhausted on a single LBA (line ~2846)
  - redundant second mark after the wedge-suspicion log (line ~2970)

All three now emit `PatchItem::NonTrimmed` instead. Failed bytes
stay "maybe" (NonTrimmed) so the next pass gets another shot. The
per-range skip-limit (10) and per-pass wedge-threshold (50) still
bound time-per-pass; they just no longer turn the bytes terminal.

The `PatchItem::Unreadable` variant stays in the enum (with
#[allow(dead_code)]) because the orchestrator-side end-of-recovery
promotion will use it: autorip, after the final retry pass
completes, scans the mapfile and promotes still-NonTrimmed →
Unreadable. That promotion lands in a follow-up commit on the
autorip side — separable from this libfreemkv change.

Loss accounting unchanged: `bytes_pending + bytes_unreadable` is
the "lost or pending" total that `abort_on_lost_secs` consults
(disc/mod.rs:1327). Moving bytes from one bucket to the other
mid-pass doesn't affect whether the rip would abort; it only
affects display (UI shows "Maybe" vs "Cosmetic") and whether
subsequent passes retry the bytes (the actual fix).

Test update: `test_pass_progress_separates_unreadable_from_pending`
was renamed to `test_pass2_leaves_failed_reads_as_pending_not_unreadable`
and rewritten to assert the new invariant — Pass 2 leaves all
failed bytes as bytes_pending (no mid-pass Unreadable promotion).
Original assertions were checking the pre-design-call behavior.

Precommit (cargo +1.86 fmt + clippy + test) green.
2026-05-10 18:47:05 -07:00
MattJackson 0bca7a11bd disc/read_error: unify Pass 1 and Pass N error handling
User's design call after watching the avoidance work prevent a wedge
on the live rip (no wedge events across 6 read errors): "Pass N
and 1 should both be very very similar in recovery. almost identical
just smaller sectors imo in pass n. pause times the same imo as a
failed read is a failed read."

The error-handling code path was already centralized (one
handle_read_error fn, called by both Disc::sweep and Disc::patch).
The TUNING was split — Pass 1 used 5 s inter-error pauses + a
wedge-skip-and-continue policy; Pass N used 1 s pauses + immediate
AbortPass on HARDWARE_ERROR / ILLEGAL_REQUEST. That asymmetry made
Pass N vulnerable to the same wedge that Pass 1's avoidance fixed.

Changes:

1. FAIL_PAUSE_SECS = 5 — single constant, applied uniformly to both
   passes. Dropped PASS_1_FAIL_PAUSE_SECS and POST_FAILURE_PAUSE_SECS
   in favor of one value. CONSECUTIVE_FAIL_LONG_PAUSE_SECS kept as a
   distinct (but currently equal) value for future tuning escalation.

2. HARDWARE_ERROR / ILLEGAL_REQUEST path is now symmetric:
   - Pass 1: JumpAhead WEDGE_JUMP_SECTORS (1 GB) + WEDGE_PAUSE_SECS
     cooldown, mark skipped region NonTrimmed.
   - Pass N: JumpAhead WEDGE_PASS_N_SKIP_SECTORS (64 sectors / 128 KB)
     + WEDGE_PAUSE_SECS cooldown. Pass N's batch=1 means a 1 GB skip
     would abandon the entire current NonTrimmed range; small skip
     moves past the bricked LBA + buffer, outer patch loop picks up
     the next sector.
   - Both share WEDGE_ABORT_THRESHOLD — same 16-skip budget before
     real AbortPass on a permanently stuck drive.

3. wedge_skip / wedge_abort tracing logs now include `pass=1|N`
   so post-mortems can see which pass hit the wedge condition.

Cost analysis:

Pre-reframe worry was "5 s × 5500 NonTrimmed sectors per Pass N
pass × 7 passes = 53 hours." Reality: most NonTrimmed sectors
recover on first or second retry, so most reads are successful and
pay 0 pause. The few that DON'T recover hit the 10-skip budget and
get marked Unreadable — bounded at 10 × 5 s = 50 s per truly-bad
sector. Worst-case Pass N pause overhead on a typical damaged disc
is single-digit minutes, not hours. And it's strictly cheaper than
the alternative (wedge kills the entire multi-pass recovery).

Tests:

- `both_passes_pause_on_failed_read_for_wedge_avoidance` — locks the
  unified pause-tuning policy (was pass_1_pauses_briefly).
- `pass_n_hardware_error_also_skips_not_aborts` — was
  `pass_n_hardware_error_still_aborts`. New behavior verified:
  JumpAhead with WEDGE_PASS_N_SKIP_SECTORS + WEDGE_PAUSE_SECS.
- `pass_n_hardware_error_aborts_after_threshold` — new. Confirms
  Pass N respects the same WEDGE_ABORT_THRESHOLD as Pass 1.
- pass_1_does_not_pause_on_skip is gone (it was the old "Pass 1
  pause=0" assertion, irrelevant after the avoidance work).

Empirical validation: avoidance was already proven on a live rip
tonight — 6 read errors on a damaged disc, sense_family=Medium
throughout, wedge_count=0, Pass 1 continued cleanly past 40%
where it previously died at 48%. This commit extends the same
discipline to Pass N's recovery loop.

Precommit (cargo +1.86 fmt + clippy + test) green.
2026-05-10 18:04:01 -07:00
MattJackson 72ab714c1e labels/deluxe: Phase D rewrite against ground-truth binding pattern
Replaces the speculative arg-position heuristic with type-presence
detection driven by real disc bytecode. Ground truth captured in
(internal)/research/deluxe-poc/data/ via POC v0.3 binding-
bytecode dumps against disc-01 (Disney) and disc-09 (Warner).

What changed:

1. StackVal::CodingType(String) — new variant. getstatic against
   org/bluray/ti/CodingType (the BD-J spec codec enum) now pushes
   this, carrying the field name (e.g. DOLBY_LOSSLESS_AUDIO). The
   pre-fix code was treating codecs as a Deluxe-internal enum
   subclass walk (Phase B), which is the wrong model — codecs are
   standard BD-J API references.

2. coding_type_to_codec_hint(field) — new function. Maps
   org.bluray.ti.CodingType field names to human-readable codec
   strings (DOLBY_LOSSLESS_AUDIO -> "Dolby TrueHD", DOLBY_AC3_AUDIO
   -> "Dolby Digital", etc.). Unknown field names pass through
   verbatim so future codec values still surface something.

3. find_binding_classes — multi-class variant. Some Deluxe discs
   split per-stream tables across two binding classes (audio +
   subtitle). Returns top-K candidates by getstatic count, filtered
   to >=40% of the top count and capped at 4. Replaces the old
   single-class find_binding_class (which was unused after this
   change).

4. interpret_streams — rewritten. Args identified by TYPE not
   position:
   - First EnumRef{kind:"Language"} -> language
   - First EnumRef{kind:"Purpose"}  -> purpose
   - First CodingType(name)         -> codec_hint
   - First Int(n)                   -> stream index hint (traced
     only; per-type sequential stream_number still wins because BD
     spec stream-numbering is anchored on MPLS)
   - Construction has CodingType -> Audio stream; otherwise Subtitle
   - No Language -> skip (not a stream construction)

   This handles BOTH the Disney 5-arg pattern (I, Lbe, Llp, I,
   LCodingType) and the Warner 4-arg pattern (I, Law, Lgp,
   LCodingType) automatically — same code path because args are
   identified by type rather than constructor-signature shape.

5. parse() now walks all binding-class candidates and unions
   their constructions before calling interpret_streams. Logs each
   candidate at INFO with getstatic_count for diagnosis.

Tests:
- 2 new tests verify the CodingType -> codec_hint mapping for
  known + unknown field names.
- Existing interpret_streams tests updated to use the new
  signature (dropped CodecTable arg).
- Audio-emission test rewritten to use CodingType arg instead of
  the old binding_type substring-match approach.

Confidence is still Medium for now (single-corpus verification);
ready to promote to High once tested against a third Deluxe disc.

Precommit (cargo +1.86 fmt + clippy + test) green.
2026-05-10 17:32:29 -07:00
MattJackson d7fb1b35ed disc: emit Pass 1 summary INFO log at sweep exit
Wires the existing PassSummary infrastructure (in read_error.rs as
of a832bad) into the sweep loop's exit path. One INFO log line per
Pass 1 completion gives operators an at-a-glance damage profile
without grepping per-error WARN lines:

  INFO pass1_summary  total_reads_ok=384521 total_errors=5
                      zones_entered=1 jumps_taken=2
                      bytes_good=38_725_644_288 bytes_pending=46_GB
                      copy_elapsed_ms=1751650

Particularly useful for post-mortem analysis when combined with
the per-error structured WARN logs (ms_since_last_error /
ms_since_last_success / sense_family / wedge_transition) shipped
in 0.18.10. Single line tells you the pass shape; preceding WARN
lines tell you the per-error detail.

Pass N (Disc::patch) intentionally NOT covered in this commit —
Pass N has its own retry-budget summary semantics that warrant a
separate design pass. Pass 1 sweep is where wedge incidents
originate, so it gets the diagnostic surface first.

Staged for 0.18.11. 0.18.10 already shipped the per-error WARN
layer; this is the finishing companion log.
2026-05-10 17:21:37 -07:00
MattJackson 06be4defd2 v0.18.10: bump version 2026-05-10 17:14:52 -07:00
MattJackson a832bad697 disc: structured timing + transition diagnostics for read errors
Adds the observability we need to debug wedge incidents from logs
alone — without needing to enable verbose TRACE-level SCSI tracing.
Goal stated by user: "when error occurs we can debug and code
correctly."

Pre-fix the WARN log on each read error showed only sense codes
and consecutive_failures. Missing: timing context (was the failed
read fast or slow?), gap to previous events (cumulative vs.
immediate failure?), and family transitions (did the drive just
flip into wedge mode, or has it been there?).

New fields on ReadCtx (no caller signature change):

  last_success_at: Option<Instant>
  last_error_at: Option<Instant>
  last_error_family: Option<SenseFamily>
  total_errors: u64
  total_reads_ok: u64
  zones_entered: u64
  jumps_taken: u64
  in_damage_zone: bool

New SenseFamily enum (NotReady / Medium / Hardware / IllegalRequest
/ Other) with is_wedge_family predicate.

handle_read_error WARN log now carries:
  consecutive_failures
  consecutive_outer_failures
  ms_since_last_error    NEW gap between this and previous error
  ms_since_last_success  NEW gap to last good read
  total_errors           NEW aggregate this pass
  total_reads_ok         NEW
  wedge_count
  sense_family           NEW typed category, easier to filter
  sense_key / asc / ascq (existing)

NEW WARN log "wedge_transition" fires once when the sense family
changes from non-wedge to wedge (Medium to Hardware/IllegalRequest).
That's the moment the drive's firmware flipped into fast-fail
mode. Single timestamped event in the log so post-mortems can
pinpoint the transition without scanning thousands of TRACE lines.

Worked example: if the next wedge incident shows

  read_error  ms_since_last_success=18234  ms_since_last_error=null
  read_error  ms_since_last_success=28000  ms_since_last_error=10000
  read_error  ms_since_last_success=43000  ms_since_last_error=68
                                          (drive returned <100ms = wedge symptom)
  wedge_transition  errors_in_zone=5  ms_since_last_success=43000

we can immediately tell cumulative damage, 5 errors over 43 s,
drive went into fast-fail mode at the 5th. If instead we see

  read_error  ms_since_last_success=200  ms_since_last_error=null  sense_family=Hardware
  wedge_transition  errors_in_zone=1

the wedge was triggered by ONE read at a physically-bricked LBA
(immediate fast-fail, no warm-up).

These two patterns demand different tuning responses (longer
pause vs. larger initial jump), and now we can distinguish them
from a single WARN log line each instead of needing TRACE
verbose for the whole rip.

Plus jumps_taken / zones_entered counters that feed an end-of-pass
INFO summary (PassSummary). Caller invokes pass_summary at sweep
end and logs structured stats: "Pass 1 saw N errors / M ok reads
/ K zones / J jumps". Single-line post-mortem for any rip.

No caller signature change (timing is internal to the handler;
end-of-pass summary is a new method callers opt into). Precommit
green; 433+ tests pass. Staged for the 0.18.10 release once we
have user-validation data on 0.18.9's avoidance tuning.
2026-05-10 17:10:52 -07:00
48 changed files with 5160 additions and 2190 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.18.9"
version = "0.20.7"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+57
View File
@@ -247,6 +247,7 @@ fn validate_processing_key(
/// Find Verify Media Key Record (type 0x10) in MKB.
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
let mut pos = 0;
let mut type10_seen: Vec<(usize, usize)> = Vec::new();
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
@@ -254,14 +255,32 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
break;
}
if rec_type == 0x10 {
type10_seen.push((pos, rec_len));
}
if rec_type == 0x10 && rec_len >= 20 {
// mk_dv is at offset 4 (after record header)
let mut dv = [0u8; 16];
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_found",
pos,
rec_len,
"mk_dv extracted from MKB"
);
return Some(dv);
}
pos += rec_len;
}
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_not_found",
type10_seen = ?type10_seen,
scanned_bytes = pos,
"no 0x10 record with rec_len>=20 found"
);
None
}
@@ -628,32 +647,70 @@ pub fn resolve_keys(
}
};
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_start",
aacs2,
bus_encryption,
disc_hash = %hash_hex,
mkb_present = mkb_data.is_some(),
"resolve_keys: starting"
);
// Path 1: Look up VUK by disc hash in KEYDB
if let Some(entry) = keydb.find_disc(&hash_hex) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
if let Some(vuk) = entry.vuk {
return Some(build(vuk, 1));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_no_vuk", "disc hash entry has no VUK");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
}
// Path 2: Find entry with matching VID → derive VUK from MK + VID
let mut path2_mk_did_count = 0usize;
for entry in keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
path2_mk_did_count += 1;
if did == *volume_id {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
return Some(build(derive_vuk(&mk, volume_id), 2));
}
}
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
// Path 3: MKB + processing keys → media key → VUK
if let Some(mkb) = mkb_data {
let mk_dv = mkb_find_mk_dv(mkb);
let subdiff = mkb_find_subdiff_records(mkb);
let cvalues = mkb_find_cvalues(mkb);
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_mkb_records",
mk_dv_found = mk_dv.is_some(),
subdiff_found = subdiff.is_some(),
subdiff_len = subdiff.as_ref().map(|s| s.len()).unwrap_or(0),
cvalues_found = cvalues.is_some(),
cvalues_len = cvalues.as_ref().map(|c| c.len()).unwrap_or(0),
"MKB record scan results"
);
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
return Some(build(derive_vuk(&mk, volume_id), 3));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = keydb.processing_keys.len(), "PK derivation failed");
// Path 4: MKB + device keys → processing key → media key → VUK
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
return Some(build(derive_vuk(&mk, volume_id), 4));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = keydb.device_keys.len(), "DK derivation failed");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
}
None
+158 -1
View File
@@ -20,6 +20,35 @@ pub struct ClipInfo {
pub ep_coarse: Vec<EpCoarse>,
/// Fine EP entries for the primary video stream
pub ep_fine: Vec<EpFine>,
/// Per-stream metadata from the ProgramInfo section (BD spec).
/// Cross-validates the MPLS STN view — see `labels/clpi.rs`.
/// Empty when program_info is missing or malformed.
pub streams: Vec<ClpiStream>,
}
/// One stream descriptor from the CLPI ProgramInfo / stream_coding_info
/// table. Mirrors the same fields the MPLS STN table carries — see
/// `mpls::StreamEntry` for the playlist-side equivalent.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ClpiStream {
/// PID of the stream in the MPEG-TS (matches MPLS).
pub pid: u16,
/// SCSI/BD coding type byte (0x80 LPCM, 0x83 TrueHD, 0x86 DTS-HD MA,
/// 0x90 PG, etc.). See `labels::mpls_universal::coding_type_to_codec_hint`.
pub coding_type: u8,
/// ISO 639-2 3-char language code. Empty for video streams.
pub language: String,
/// Audio format byte (1=mono, 3=stereo, 6=5.1, 12=7.1).
/// Zero for non-audio streams.
pub audio_format: u8,
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz). Zero for non-audio.
pub audio_rate: u8,
/// Video format byte (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p).
/// Zero for non-video.
pub video_format: u8,
/// Video rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94).
pub video_rate: u8,
}
#[derive(Debug, Clone)]
@@ -128,7 +157,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
// Header offsets
let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
let _prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
let prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
let cpi_start = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
// ClipInfo section at offset 40
@@ -139,6 +168,16 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
0
};
// Parse ProgramInfo (per-stream language + codec). Best-effort:
// malformed program_info doesn't fail the parse, just gives an
// empty streams list. EP map is unaffected — sector-range lookups
// continue to work.
let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() {
parse_program_info(&data[prog_info_start..])
} else {
Vec::new()
};
// Parse CPI / EP Map
let (ep_coarse, ep_fine) = if cpi_start > 0 && cpi_start + 8 < data.len() {
parse_cpi(&data[cpi_start..])?
@@ -151,9 +190,127 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
source_packet_count,
ep_coarse,
ep_fine,
streams,
})
}
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
/// language, codec sub-fields). Layout per BD spec / libbluray
/// clpi_parse.c:
///
/// ```text
/// ProgramInfo:
/// length: 4 bytes
/// reserved: 1 byte
/// num_programs: 1 byte
/// for each program:
/// spn_program_sequence_start: 4 bytes
/// program_map_pid: 2 bytes
/// num_streams: 1 byte
/// num_groups: 1 byte
/// for each stream:
/// pid: 2 bytes
/// stream_coding_info_length: 1 byte
/// stream_coding_info: (varies by coding_type)
/// coding_type: 1 byte
/// per-type bytes (see match arms below)
/// ```
///
/// Returns `Vec::new()` on any structural mismatch — we don't propagate
/// errors because the EP map is the primary CLPI output, and a corrupt
/// program_info shouldn't break sector-range lookups.
fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
let mut out = Vec::new();
if data.len() < 6 {
return out;
}
// length: 4 bytes (skipped — we trust the section bounds in the
// caller's slice and read the bytes that follow). Reserved 1 byte
// at offset 4. num_programs at offset 5.
let num_programs = data[5] as usize;
let mut pos = 6usize;
for _ in 0..num_programs {
// Program header: 4 (spn) + 2 (pmt_pid) + 1 (num_streams) + 1 (num_groups) = 8 bytes
if pos + 8 > data.len() {
return out;
}
let num_streams = data[pos + 6] as usize;
pos += 8;
for _ in 0..num_streams {
// Stream header: 2 (pid) + 1 (sci_length) + sci bytes
if pos + 3 > data.len() {
return out;
}
let pid = u16::from_be_bytes([data[pos], data[pos + 1]]);
let sci_len = data[pos + 2] as usize;
let sci_end = pos + 3 + sci_len;
if sci_end > data.len() || sci_len < 1 {
return out;
}
let sci = &data[pos + 3..sci_end];
let coding_type = sci[0];
let mut audio_format = 0u8;
let mut audio_rate = 0u8;
let mut video_format = 0u8;
let mut video_rate = 0u8;
let mut language = String::new();
match coding_type {
// Video — MPEG-2 (0x02), H.264 (0x1B), HEVC (0x24)
0x02 | 0x1B | 0x24 => {
if sci.len() >= 2 {
video_format = (sci[1] >> 4) & 0x0F;
video_rate = sci[1] & 0x0F;
}
}
// Primary audio — LPCM(0x80), AC-3(0x81), DTS(0x82),
// TrueHD(0x83), AC-3+(0x84), DTS-HD(0x85), DTS-HD MA(0x86)
0x80..=0x86 => {
if sci.len() >= 2 {
audio_format = (sci[1] >> 4) & 0x0F;
audio_rate = sci[1] & 0x0F;
}
if sci.len() >= 5 {
language = String::from_utf8_lossy(&sci[2..5]).to_string();
}
}
// Secondary audio (0xA1 AC-3+, 0xA2 DTS-HD)
0xA1 | 0xA2 => {
if sci.len() >= 2 {
audio_format = (sci[1] >> 4) & 0x0F;
audio_rate = sci[1] & 0x0F;
}
if sci.len() >= 5 {
language = String::from_utf8_lossy(&sci[2..5]).to_string();
}
}
// PG (0x90), IG (0x91): coding_type + 3-byte language [+ char_code for PG]
0x90 | 0x91 => {
if sci.len() >= 4 {
language = String::from_utf8_lossy(&sci[1..4]).to_string();
}
}
_ => {}
}
out.push(ClpiStream {
pid,
coding_type,
language,
audio_format,
audio_rate,
video_format,
video_rate,
});
pos = sci_end;
}
}
out
}
/// Parse the CPI section containing the EP map.
fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
if data.len() < 8 {
+2 -2
View File
@@ -16,7 +16,7 @@ pub mod lfsr;
pub(crate) mod tables;
use crate::disc::Extent;
use crate::sector::SectorReader;
use crate::sector::SectorSource;
/// CSS decryption state for a DVD title.
#[derive(Debug, Clone)]
@@ -34,7 +34,7 @@ pub struct CssState {
/// 0x80 (start of the encrypted region). This only happens when a new PES
/// packet begins at exactly sector offset 128. We scan up to 50000
/// scrambled sectors sequentially across all extents.
pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option<CssState> {
pub fn crack_key(reader: &mut dyn SectorSource, extents: &[Extent]) -> Option<CssState> {
let mut tried = 0u32;
let max_tries = 50_000;
+4 -4
View File
@@ -3,13 +3,13 @@
use super::*;
use crate::clpi;
use crate::mpls;
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf;
impl Disc {
/// Scan Blu-ray titles from MPLS playlists.
pub(super) fn scan_bluray_titles(
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Vec<DiscTitle> {
let mut titles = Vec::new();
@@ -31,7 +31,7 @@ impl Disc {
}
pub(super) fn parse_playlist(
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
filename: &str,
data: &[u8],
@@ -199,7 +199,7 @@ impl Disc {
/// Prefers English, falls back to first available language.
/// Returns None if META directory is empty or XML has no usable title.
pub(super) fn read_meta_title(
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Option<String> {
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
+2 -2
View File
@@ -2,13 +2,13 @@
use super::*;
use crate::ifo;
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf;
impl Disc {
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
pub(super) fn scan_dvd_titles(
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Vec<DiscTitle> {
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
+104 -8
View File
@@ -2,7 +2,7 @@
use super::*;
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf;
/// Result of SCSI AACS handshake (ECDH authentication).
@@ -22,42 +22,103 @@ impl Disc {
) -> Option<HandshakeResult> {
use crate::aacs::{self, KeyDb};
let keydb_path = opts.resolve_keydb()?;
let keydb = KeyDb::load(&keydb_path).ok()?;
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_entry",
"do_handshake entered"
);
let keydb_path = match opts.resolve_keydb() {
Some(p) => p,
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_no_keydb",
"no KEYDB found in search paths; handshake skipped"
);
return None;
}
};
let keydb = match KeyDb::load(&keydb_path) {
Ok(db) => db,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_keydb_load_failed",
io_error_kind = ?e.kind(),
keydb = %keydb_path.display(),
"KEYDB load failed; handshake skipped"
);
return None;
}
};
let host_cert_count = keydb.host_certs.len();
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_start",
host_cert_count,
keydb = %keydb_path.display(),
"handshake starting"
);
const MAX_CERT_ATTEMPTS: usize = 16;
for hc in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS) {
let mut last_err_code: Option<u16> = None;
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
Ok(mut auth) => {
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
Ok(vid) => vid,
Err(_) => return None, // handshake succeeded but can't read VID
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_vid_read_failed",
cert_index = idx,
error_code = e.code(),
"auth ok but volume ID read failed"
);
return None;
}
};
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
.ok()
.map(|(rdk, _)| rdk);
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_ok",
cert_index = idx,
has_read_data_key = read_data_key.is_some(),
);
return Some(HandshakeResult {
volume_id,
read_data_key,
});
}
Err(_) => {
Err(e) => {
last_err_code = Some(e.code());
continue;
}
}
}
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_all_certs_failed",
host_cert_count,
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
last_error_code = last_err_code,
"all host certs in KEYDB rejected by drive"
);
// All host certs failed — return None, not a fake success
None
}
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
///
/// Reads AACS files from UDF (via SectorReader), resolves keys through
/// Reads AACS files from UDF (via SectorSource), resolves keys through
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
pub(super) fn resolve_encryption(
udf_fs: &udf::UdfFs,
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
keydb_path: &std::path::Path,
handshake: Option<&HandshakeResult>,
) -> Result<AacsState> {
@@ -73,6 +134,19 @@ impl Disc {
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Log the disc hash so we can confirm whether it's present in KEYDB
// when key resolution fails. The disc hash is SHA-1 of the full
// Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by.
let dh = crate::aacs::disc_hash(&uk_ro_data);
let dh_hex = crate::aacs::disc_hash_hex(&dh);
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_disc_hash",
disc_hash = %dh_hex,
uk_ro_len = uk_ro_data.len(),
"disc hash computed (compare with keydb.cfg entries)"
);
let cc_data = udf_fs
.read_file(reader, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
@@ -84,6 +158,28 @@ impl Disc {
.ok();
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
let mkb_first_64_hex = mkb_data
.as_deref()
.map(|m| {
m.iter()
.take(64)
.map(|b| format!("{b:02x}"))
.collect::<String>()
})
.unwrap_or_default();
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_mkb_info",
mkb_present = mkb_data.is_some(),
mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0),
mkb_version = ?mkb_ver,
mkb_first_64 = %mkb_first_64_hex,
keydb_disc_count = keydb.disc_entries.len(),
keydb_dk_count = keydb.device_keys.len(),
keydb_pk_count = keydb.processing_keys.len(),
"AACS resolution inputs"
);
// Use handshake volume ID if available, otherwise zeros
// (KEYDB VUK lookup by disc hash works without volume ID)
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
+136 -1179
View File
File diff suppressed because it is too large Load Diff
+1238 -9
View File
File diff suppressed because it is too large Load Diff
+362 -104
View File
@@ -72,6 +72,73 @@ pub struct ReadCtx {
/// `WEDGE_ABORT_THRESHOLD` consecutive wedges with no good read
/// in between → real AbortPass.
pub wedge_count: u64,
// ── Diagnostic counters (added 2026-05-10) ──
//
// Aggregate state for post-mortem analysis of wedge incidents.
// Every Pass 1 / Pass N sweep now produces a structured summary
// at the WARN log on each error AND an end-of-pass INFO summary.
// Goal: when a wedge happens, the operator should be able to tell
// from the logs whether it was triggered by ONE read at a
// physically-damaged sector (immediate failure) or by accumulated
// exposure across MANY reads (firmware-state buildup), and what
// the timing pattern looked like.
/// `Instant` of the most recent successful read. Used to compute
/// "time since last good" for the WARN log on each error. None
/// before the first successful read.
pub last_success_at: Option<std::time::Instant>,
/// `Instant` of the most recent failed read. Used to compute
/// "time since last error" for the WARN log. None before the
/// first error.
pub last_error_at: Option<std::time::Instant>,
/// Last error's sense-key "family" (Medium / Hardware / IllegalRequest
/// / NotReady / Other). Used to detect WEDGE TRANSITIONS — when
/// the family changes from Medium → Hardware/IllegalRequest, the
/// drive almost certainly just entered fast-fail mode. That
/// transition gets its own WARN log so the trace is unambiguous.
pub last_error_family: Option<SenseFamily>,
/// Sum of all errors observed during this sweep. Reported in the
/// end-of-pass summary.
pub total_errors: u64,
/// Sum of all successful reads during this sweep.
pub total_reads_ok: u64,
/// Count of damage zones entered (transitions from clean → in-damage).
pub zones_entered: u64,
/// Count of damage-jumps executed during this sweep.
pub jumps_taken: u64,
/// True between "first error after a clean period" and "16 consecutive
/// good reads after the last error in the cluster." Used to count
/// zone entries and to bound zone_reads accurately.
pub in_damage_zone: bool,
}
/// Coarse classification of a SCSI sense key for diagnostic logging.
/// Wedge-family events (Hardware + IllegalRequest) get their own
/// transition log when the sense family changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SenseFamily {
NotReady,
Medium,
Hardware,
IllegalRequest,
Other,
}
impl SenseFamily {
pub fn from_sense_key(sense_key: u8) -> Self {
match sense_key {
scsi::SENSE_KEY_NOT_READY => SenseFamily::NotReady,
scsi::SENSE_KEY_MEDIUM_ERROR => SenseFamily::Medium,
scsi::SENSE_KEY_HARDWARE_ERROR => SenseFamily::Hardware,
scsi::SENSE_KEY_ILLEGAL_REQUEST => SenseFamily::IllegalRequest,
_ => SenseFamily::Other,
}
}
/// True for the "wedge family" — Hardware + IllegalRequest are
/// the senses the BU40N firmware returns in its fast-fail state.
pub fn is_wedge_family(self) -> bool {
matches!(self, SenseFamily::Hardware | SenseFamily::IllegalRequest)
}
}
impl ReadCtx {
@@ -80,8 +147,13 @@ impl ReadCtx {
/// is the one that grinds on the bad ranges. So bisect-on-
/// marginal is OFF (failed batches become SkipBlock; whole 32-
/// sector blocks marked NonTrimmed for Pass N to revisit), and
/// the damage-jump fast-path triggers after just 4 consecutive
/// outer-batch failures.
/// the damage-jump fast-path triggers after just 1 consecutive
/// outer-batch failure — the user's wedge-prevention principle
/// (2026-05-11): once the drive returns ANY recoverable error,
/// retrying the same LBA quickly is what triggers the firmware
/// fast-fail transition. Jump immediately, never retry in Pass 1.
/// Pass N owns retries — it gets per-sector timeouts that don't
/// hammer the firmware the same way.
pub fn for_sweep(batch: u16) -> Self {
Self {
batch,
@@ -91,13 +163,21 @@ impl ReadCtx {
damage_window: Vec::with_capacity(16),
damage_window_max: 16,
damage_threshold_pct: 12,
fast_jump_threshold: 4,
fast_jump_threshold: 1,
jump_multiplier: 1,
not_ready_retries: 0,
bridge_degradation_count: 0,
bisecting: false,
bisect_on_marginal: false,
wedge_count: 0,
last_success_at: None,
last_error_at: None,
last_error_family: None,
total_errors: 0,
total_reads_ok: 0,
zones_entered: 0,
jumps_taken: 0,
in_damage_zone: false,
}
}
@@ -124,6 +204,14 @@ impl ReadCtx {
bisecting: false,
bisect_on_marginal: true,
wedge_count: 0,
last_success_at: None,
last_error_at: None,
last_error_family: None,
total_errors: 0,
total_reads_ok: 0,
zones_entered: 0,
jumps_taken: 0,
in_damage_zone: false,
}
}
@@ -146,7 +234,40 @@ impl ReadCtx {
if self.damage_window.len() > self.damage_window_max {
self.damage_window.remove(0);
}
// Diagnostic state.
self.total_reads_ok += 1;
self.last_success_at = Some(std::time::Instant::now());
// If we were in a damage zone and accumulated enough good
// reads to exit (damage_window now all-good), the zone is
// over. Don't reset zones_entered — that's a sweep total.
if self.in_damage_zone && self.consecutive_good >= self.damage_window_max as u64 {
self.in_damage_zone = false;
self.last_error_family = None;
}
}
/// Final per-pass summary suitable for an INFO log at the end of
/// `sweep` / `patch`. Caller renders this to a single structured
/// log line.
pub fn pass_summary(&self) -> PassSummary {
PassSummary {
total_reads_ok: self.total_reads_ok,
total_errors: self.total_errors,
zones_entered: self.zones_entered,
jumps_taken: self.jumps_taken,
}
}
}
/// End-of-pass stats logged at INFO for post-mortem analysis. Lets
/// an operator answer "how damaged is this disc?" from a single log
/// line per pass.
#[derive(Debug, Clone, Copy)]
pub struct PassSummary {
pub total_reads_ok: u64,
pub total_errors: u64,
pub zones_entered: u64,
pub jumps_taken: u64,
}
/// What the caller should do after a read failure. The caller owns the
@@ -178,7 +299,42 @@ pub enum ReadAction {
// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing
// bridge wedges 524 ms after a 5.4-second internal ECC retry. The
// post-failure pauses give the drive — and the bridge — time to settle.
const POST_FAILURE_PAUSE_SECS: u64 = 1;
/// Pause between a failed read and the next read attempt — applied
/// uniformly to Pass 1 sweep and Pass N patch.
///
/// 2026-05-11 reframe: a failed read is a failed read, regardless of
/// which pass is running. The prior split (1s for Pass N, 5s for Pass
/// 1 via `PASS_1_FAIL_PAUSE_SECS`) was solving an imaginary cost
/// problem — real damaged-disc cases mark <50 MB NonTrimmed, and the
/// extra 5s/error is single-digit minutes per pass, not hours. The
/// cost of NOT pausing — a drive wedge that aborts the entire
/// multi-pass recovery — is much worse.
///
/// The wedge avoidance principle: error → drive ECC retry (5-10s
/// internal) → return → cooldown pause → next read. Same shape
/// everywhere reads can fail.
const FAIL_PAUSE_SECS: u64 = 5;
/// Long cooldown applied when a damage zone is first entered (the
/// FIRST read failure after a clean run, before the drive has had a
/// chance to cycle in retries that push it toward fast-fail).
///
/// Empirical: 2026-05-11 Dune Pt 2 wedge incident showed 7 medium
/// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the
/// BU40N's firmware into IllegalRequest fast-fail mode permanently.
/// Once there, only physical eject + reload clears it. Giving the
/// drive 30s of breathing room after the FIRST error in a zone —
/// before we start adding more error counts in the firmware's
/// internal window — prevents the transition.
///
/// Cost on clean discs: zero (first-error path doesn't trigger).
/// Cost on damaged discs: ~30s × N damage zones; on a 5-zone disc
/// that's 2.5 min extra. Trade for never wedging the drive.
const ZONE_ENTRY_COOLDOWN_SECS: u64 = 30;
/// Cooldown when a long streak of failures suggests the drive is
/// stuck in a damage zone and needs MORE breathing room than the
/// standard inter-error pause. Same value as `FAIL_PAUSE_SECS`
/// because empirically 5s is enough; kept as a separate name so the
/// escalation policy is explicit at the call site.
const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5;
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2;
@@ -187,14 +343,6 @@ const NOT_READY_MAX_RETRIES: u32 = 3;
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15;
const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;
/// Pass 1 inter-error pause. ZERO on the clean path (sweep zooms
/// past damage windows) but applied to every failed read so the
/// drive's firmware gets cool-down time between damage-cluster
/// exposures. Wedge-avoidance change 2026-05-10: pre-fix `pause_secs
/// = 0` on Pass 1 errors caused back-to-back exposures that
/// accumulated firmware wedge state.
const PASS_1_FAIL_PAUSE_SECS: u64 = 5;
/// Base of the damage-jump distance formula: `jump_sectors =
/// JUMP_BASE_SECTORS × batch × jump_multiplier`. Bumped 2026-05-10
/// from 256 → 1024 (4×) so the first damage-jump at batch=32 covers
@@ -244,6 +392,15 @@ const WEDGE_PAUSE_SECS: u64 = 30;
/// a permanently bricked drive.
const WEDGE_ABORT_THRESHOLD: u64 = 16;
/// Pass-N wedge-skip distance. Pass N's batch=1 reads target
/// specific NonTrimmed sectors from Pass 1, so a big 1 GB skip
/// would blow past the current NonTrimmed range and abandon many
/// sectors that might still recover. Use a smaller skip just to
/// move past the bricked LBA + a small buffer — the outer patch
/// loop's next iteration picks up the next sector in the same or
/// next range.
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;
/// THE single error-handling entry point. Updates `ctx`, returns the
/// action the caller must apply.
///
@@ -261,12 +418,53 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
ctx.consecutive_outer_failures += 1;
}
// Diagnostic instrumentation — compute timing context BEFORE
// mutating the timestamps so the log reflects the gap to the
// PREVIOUS error / success, not zero.
let now = std::time::Instant::now();
let ms_since_last_error = ctx
.last_error_at
.map(|t| now.duration_since(t).as_millis() as u64);
let ms_since_last_success = ctx
.last_success_at
.map(|t| now.duration_since(t).as_millis() as u64);
let current_family = err
.scsi_sense()
.map(|s| SenseFamily::from_sense_key(s.sense_key))
.unwrap_or(SenseFamily::Other);
// Zone-entry tracking: this is the first error after a clean run
// (or the first error of the sweep).
if !ctx.in_damage_zone && !ctx.bisecting {
ctx.in_damage_zone = true;
ctx.zones_entered += 1;
}
ctx.total_errors += 1;
ctx.last_error_at = Some(now);
// Wedge transition: previous error was MEDIUM, this one is
// HARDWARE or ILLEGAL_REQUEST. That's the moment the drive's
// firmware flipped into fast-fail mode. Distinct WARN so logs
// make it unambiguous when the wedge "started."
let is_wedge_transition = matches!(ctx.last_error_family, Some(prev) if !prev.is_wedge_family())
&& current_family.is_wedge_family();
ctx.last_error_family = Some(current_family);
tracing::warn!(
target: "freemkv::disc",
phase = "read_error",
consecutive_failures = ctx.consecutive_failures,
consecutive_outer_failures = ctx.consecutive_outer_failures,
ms_since_last_error,
ms_since_last_success,
total_errors = ctx.total_errors,
total_reads_ok = ctx.total_reads_ok,
batch = ctx.batch,
bisecting = ctx.bisecting,
wedge_count = ctx.wedge_count,
sense_family = ?current_family,
sense_key = err.scsi_sense().map(|s| s.sense_key),
asc = err.scsi_sense().map(|s| s.asc),
ascq = err.scsi_sense().map(|s| s.ascq),
@@ -274,6 +472,17 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
"read failed; classifying"
);
if is_wedge_transition {
tracing::warn!(
target: "freemkv::disc",
phase = "wedge_transition",
errors_in_zone = ctx.total_errors,
ms_since_last_success,
new_family = ?current_family,
"drive entered wedge / fast-fail family (was returning recoverable medium errors before this)"
);
}
// 1. Transport failure: bridge crash / USB disconnect. The outer
// pass loop knows how to handle this (rediscover sg path,
// re-open drive). Inline single-sector retry here was tried in
@@ -311,27 +520,27 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
// 4. Hardware error / illegal request — the firmware-wedge family.
// The drive transitioned into a fast-fail state where it
// rejects reads near the LBA. Two policies:
// rejects reads near the LBA. Same response shape for both
// passes (2026-05-11 reframe — error handling is centralized,
// and the wedge is a code-induced state we can avoid via
// pacing + skip):
//
// Pass 1 sweep (bisect_on_marginal=false): the wedge is
// *recoverable* by skipping. Jump a large distance ahead
// (1 GB), pause for cooldown, mark the skipped region
// NonTrimmed so Pass N revisits. Allow up to
// WEDGE_ABORT_THRESHOLD consecutive wedges before truly
// giving up. This replaces the pre-fix "abort the entire
// rip on first wedge" behavior that caused 48%-and-die
// failures on discs with one bad cluster.
// - Pass 1 sweep (bisect_on_marginal=false): jump
// WEDGE_JUMP_SECTORS (1 GB) ahead, pause WEDGE_PAUSE_SECS,
// mark skipped region NonTrimmed.
// - Pass N patch (bisect_on_marginal=true): give up on the
// current sector (the granular target), pause for cooldown,
// let the outer patch loop move to the next NonTrimmed
// range. Implemented as a small JumpAhead so the same code
// path serves both — Pass N's batch=1 means JumpAhead by
// WEDGE_PASS_N_SKIP_SECTORS effectively skips just this
// sector and a small buffer (gives the drive room to
// recover before the next per-sector attempt).
//
// Pass N patch (bisect_on_marginal=true): Pass N's job IS
// single-sector recovery; a wedge means the drive won't
// give us the specific sectors we asked for. Skipping
// doesn't help here. Abort and let autorip decide whether
// to retry, eject, or surface the failure to the user.
// Both paths share the WEDGE_ABORT_THRESHOLD budget — only
// AbortPass after N consecutive wedges with no successful
// read in between.
if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
if ctx.bisect_on_marginal {
return ReadAction::AbortPass;
}
// Pass 1 wedge-skip path.
if !ctx.bisecting {
ctx.wedge_count += 1;
}
@@ -341,20 +550,28 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
phase = "wedge_abort",
wedge_count = ctx.wedge_count,
threshold = WEDGE_ABORT_THRESHOLD,
"Pass 1 wedge-skip exhausted — drive appears permanently stuck"
pass = if ctx.bisect_on_marginal { "N" } else { "1" },
"wedge-skip exhausted — drive appears permanently stuck"
);
return ReadAction::AbortPass;
}
let jump_sectors = if ctx.bisect_on_marginal {
WEDGE_PASS_N_SKIP_SECTORS
} else {
WEDGE_JUMP_SECTORS
};
tracing::warn!(
target: "freemkv::disc",
phase = "wedge_skip",
pass = if ctx.bisect_on_marginal { "N" } else { "1" },
wedge_count = ctx.wedge_count,
jump_sectors = WEDGE_JUMP_SECTORS,
jump_sectors,
pause_secs = WEDGE_PAUSE_SECS,
"Pass 1 wedge detected — jumping ahead and pausing for drive cooldown"
"wedge detected — skipping ahead and pausing for drive cooldown"
);
ctx.jumps_taken += 1;
return ReadAction::JumpAhead {
sectors: WEDGE_JUMP_SECTORS,
sectors: jump_sectors,
pause_secs: WEDGE_PAUSE_SECS,
};
}
@@ -403,35 +620,31 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
bad_count * 100 / ctx.damage_window.len()
};
// Inter-error pause on Pass 1 — wedge avoidance.
// Inter-error pause — wedge prevention via pacing.
//
// Pre-2026-05-10 Pass 1 ran `pause_secs = 0` on all errors so
// sweep would zoom past damage zones in seconds instead of
// minutes. Empirically on the BU40N this caused firmware-wedge
// events: each read failure leaves residual state in the drive's
// firmware, and back-to-back errors without cooldown accumulate
// toward a wedge threshold. We hit wedge on Dune Pt 2 after 5
// errors in 43 s spread over a 140 MB damage cluster — even
// though wall-clock pacing was slow, the drive had no breathing
// room between exposures.
// Zone-entry case (first error after a clean run): apply the
// long ZONE_ENTRY_COOLDOWN_SECS pause. The empirical wedge
// observed 2026-05-11 happened ~7 errors into a damage zone,
// each retry adding to the firmware's internal counter. A 30s
// pause at zone entry lets the drive's bridge / firmware
// counters reset before we issue the next read.
//
// New policy: Pass 1 keeps `pause = 0` on the CLEAN path
// (successful reads zoom past damage windows fine), but every
// failed read takes a PASS_1_FAIL_PAUSE_SECS pause before the
// next read. Cost: ~5 s extra per scattered failure, ~30-60 s
// total in a damage cluster — trivial compared to the alternative
// of crashing the whole rip at 48%. Long failure streaks still
// escalate via CONSECUTIVE_FAIL_LONG_PAUSE.
let pause_secs = if !ctx.bisect_on_marginal {
if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
} else {
PASS_1_FAIL_PAUSE_SECS
}
// Subsequent errors in the same zone: the standard 5s pause.
// (We've already jumped past the initial damage; further errors
// mean we landed in another bad cluster — same pacing applies.)
//
// Long-streak escalation: same 5s currently; kept as a separate
// branch for future tuning. Pass N (bisect_on_marginal=true)
// uses the standard pauses — it's running single-sector retries
// on already-known-bad LBAs by design.
let is_zone_entry =
ctx.consecutive_outer_failures == 1 && !ctx.bisecting && !ctx.bisect_on_marginal;
let pause_secs = if is_zone_entry {
ZONE_ENTRY_COOLDOWN_SECS
} else if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
} else {
POST_FAILURE_PAUSE_SECS
FAIL_PAUSE_SECS
};
// 7. Damage-jump: too many failures → skip ahead by an escalating
@@ -474,6 +687,7 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
// doesn't keep firing fast-jump every read after the initial
// jump fired. The window-based trigger handles further jumps.
ctx.consecutive_outer_failures = 0;
ctx.jumps_taken += 1;
return ReadAction::JumpAhead {
sectors,
pause_secs: pause_secs + POST_JUMP_EXTRA_PAUSE_SECS,
@@ -535,15 +749,18 @@ mod tests {
}
#[test]
fn pass_1_marginal_skips_instead_of_bisecting() {
// Pass 1's job is "fast and accurate" — leave bisection to
// Pass N. A failed batch becomes SkipBlock (whole 32-sector
// block marked NonTrimmed for Pass N to revisit).
fn pass_1_marginal_jumps_immediately_not_bisecting() {
// 2026-05-11 wedge-prevention rewrite: Pass 1 jumps on the
// FIRST marginal error (fast_jump_threshold=1) rather than
// SkipBlock. Retrying the same LBA quickly is what triggers
// the BU40N's firmware fast-fail transition; immediate jump
// prevents the cascade. Pass N still bisects (its job is
// per-sector recovery on already-known-bad LBAs).
let mut ctx = ReadCtx::for_sweep(32);
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
ReadAction::SkipBlock { .. } => {}
other => panic!("expected SkipBlock for Pass 1, got {other:?}"),
ReadAction::JumpAhead { .. } => {}
other => panic!("expected JumpAhead on first Pass 1 marginal error, got {other:?}"),
}
}
@@ -569,26 +786,18 @@ mod tests {
}
#[test]
fn pass_1_jumps_after_4_consecutive_outer_failures() {
// The fast-entry trigger: Pass 1 should JumpAhead after 4
// consecutive outer-batch failures, BEFORE the 16-block
// damage window has filled. Otherwise we spend ~40 minutes
// of bisecting/grinding to fill the window before the first
// jump on a damage zone we entered cleanly.
fn pass_1_jumps_immediately_on_first_outer_failure() {
// 2026-05-11 rewrite: fast_jump_threshold is 1 on Pass 1, not
// 4. Even ONE error triggers a jump because BU40N's firmware
// fast-fail mode is sensitive to retry cadence. The wedge
// observed 2026-05-11 happened at 7 errors / 6.5s — by then
// we were already wedged. Jumping on error #1 means we
// physically can't reach the cascade.
let mut ctx = ReadCtx::for_sweep(32);
// First three should NOT jump (still under threshold of 4).
for _ in 0..3 {
let a = handle_read_error(&medium_err(), &mut ctx);
assert!(
!matches!(a, ReadAction::JumpAhead { .. }),
"should not jump until 4 consecutive outer failures"
);
}
// Fourth should jump.
let a = handle_read_error(&medium_err(), &mut ctx);
assert!(
matches!(a, ReadAction::JumpAhead { .. }),
"expected JumpAhead at 4th consecutive outer failure, got {a:?}"
"expected JumpAhead on first outer failure (fast_jump_threshold=1), got {a:?}"
);
}
@@ -609,12 +818,15 @@ mod tests {
#[test]
fn outer_success_resets_consecutive_outer_failures() {
// With fast_jump_threshold=1 each Pass 1 error fires a jump
// and resets `consecutive_outer_failures` to 0 inside the
// handler. So we can't accumulate "3" the old way — instead,
// verify the counter goes back to 0 after on_success too.
let mut ctx = ReadCtx::for_sweep(32);
for _ in 0..3 {
handle_read_error(&medium_err(), &mut ctx);
}
assert_eq!(ctx.consecutive_outer_failures, 3);
// An outer-success (bisecting=false) should reset the counter.
handle_read_error(&medium_err(), &mut ctx);
// After fast-jump, consecutive_outer_failures already 0.
assert_eq!(ctx.consecutive_outer_failures, 0);
// on_success keeps it at 0 (defensive).
ctx.bisecting = false;
ctx.on_success();
assert_eq!(ctx.consecutive_outer_failures, 0);
@@ -698,13 +910,40 @@ mod tests {
}
#[test]
fn pass_n_hardware_error_still_aborts() {
// Pass N (bisect_on_marginal=true) keeps the original
// AbortPass behavior — single-sector recovery can't make
// progress through a wedge, so the right answer is to bail
// and let the outer layer decide.
fn pass_n_hardware_error_also_skips_not_aborts() {
// 2026-05-11 reframe: error handling is centralized, the
// wedge is a code-induced state, and the avoidance principle
// (skip + pause + continue) applies to Pass N too. Previously
// Pass N AbortPass'd on first wedge — same fatal-at-48% bug
// Pass 1 had pre-fix. Now Pass N gets a smaller skip
// (WEDGE_PASS_N_SKIP_SECTORS, not the 1 GB Pass 1 jump)
// because Pass N's job IS to revisit specific NonTrimmed
// ranges; over-skipping abandons recoverable sectors.
let mut ctx = ReadCtx::for_patch(1);
let action = handle_read_error(&hardware_err(), &mut ctx);
match action {
ReadAction::JumpAhead {
sectors,
pause_secs,
} => {
assert_eq!(sectors, WEDGE_PASS_N_SKIP_SECTORS);
assert_eq!(pause_secs, WEDGE_PAUSE_SECS);
}
other => panic!("expected JumpAhead, got {other:?}"),
}
assert_eq!(ctx.wedge_count, 1);
}
#[test]
fn pass_n_hardware_error_aborts_after_threshold() {
// Same threshold as Pass 1 — after WEDGE_ABORT_THRESHOLD
// consecutive wedges with no good read in between, give up.
let mut ctx = ReadCtx::for_patch(1);
for _ in 0..WEDGE_ABORT_THRESHOLD - 1 {
let action = handle_read_error(&hardware_err(), &mut ctx);
assert!(matches!(action, ReadAction::JumpAhead { .. }));
}
let action = handle_read_error(&hardware_err(), &mut ctx);
assert_eq!(action, ReadAction::AbortPass);
}
@@ -741,24 +980,43 @@ mod tests {
}
#[test]
fn pass_1_pauses_briefly_on_skip_for_wedge_avoidance() {
// Pre-2026-05-10 Pass 1 ran pause_secs=0 on all errors (zoom
// past damage zones in seconds). That caused firmware wedges
// on the BU40N — back-to-back errors with no cooldown built
// up firmware state until the drive entered the wedge fast-
// fail mode. New policy: a brief inter-error pause (5 s) on
// Pass 1 to give the drive's firmware time to settle between
// damage-zone exposures. Successful reads remain zero-pause
// — only errors cost time, and only a few seconds per
// scattered failure. Trivial cost compared to crashing the
// whole rip at 48%.
fn pass_1_zone_entry_uses_long_cooldown() {
// 2026-05-11 wedge-prevention rewrite: Pass 1's FIRST error
// (zone entry) gets a 30 s ZONE_ENTRY_COOLDOWN_SECS pause +
// a 2 s POST_JUMP_EXTRA on top (since we're also jumping).
// The long pause prevents the retry cadence that triggers
// firmware fast-fail. Subsequent errors in the same zone fall
// back to the standard 5 s FAIL_PAUSE_SECS.
let mut ctx = ReadCtx::for_sweep(32);
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
ReadAction::SkipBlock { pause_secs } => {
assert_eq!(pause_secs, PASS_1_FAIL_PAUSE_SECS);
ReadAction::JumpAhead { pause_secs, .. } => {
assert_eq!(
pause_secs,
ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS,
"first-error pause should be 30 + 2 = 32 s"
);
}
other => panic!("expected SkipBlock, got {other:?}"),
other => panic!("expected JumpAhead on first Pass 1 error, got {other:?}"),
}
}
#[test]
fn pass_n_pauses_uniformly_on_failed_read() {
// Pass N (bisect_on_marginal=true) is exempt from the
// zone-entry long pause — its whole job is to retry single
// sectors on already-known-bad LBAs, and the 30 s pause every
// single-sector failure would multiply slow recovery
// pointlessly. Pass N keeps the standard 5 s FAIL_PAUSE_SECS.
let mut ctx = ReadCtx::for_patch(1);
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
ReadAction::SkipBlock { pause_secs } => assert_eq!(pause_secs, FAIL_PAUSE_SECS),
ReadAction::JumpAhead { pause_secs, .. } => {
assert_eq!(pause_secs, FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS)
}
ReadAction::Bisect => {}
other => panic!("expected pausing action, got {other:?}"),
}
}
+1 -1
View File
@@ -20,7 +20,7 @@
//! - Mapfile is single-writer (consumer-only). No locking.
//! - All `read_error::ReadCtx` state stays on the producer thread.
//! - `set_speed` calls happen on the producer thread (same thread that
//! owns the `SectorReader`). No new SCSI concurrency.
//! owns the `SectorSource`). No new SCSI concurrency.
//! - Per-iteration ordering of file-write → mapfile-record is kept
//! intact in the consumer (write before record), so the on-disk
//! invariant "mapfile only marks Finished what the file has
+2 -2
View File
@@ -29,7 +29,7 @@ use crate::platform::PlatformDriver;
use crate::platform::mt1959::Mt1959;
use crate::profile::{self, DriveProfile};
use crate::scsi::ScsiTransport;
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -707,7 +707,7 @@ fn open_block_device_for_sg(sg_path: &Path) -> Option<std::os::unix::io::RawFd>
}
}
impl SectorReader for Drive {
impl SectorSource for Drive {
fn read_sectors(
&mut self,
lba: u32,
+3 -3
View File
@@ -9,7 +9,7 @@
use crate::disc::{Codec, Resolution};
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
// ── Public types ────────────────────────────────────────────────────────────
@@ -184,7 +184,7 @@ fn bcd_byte(b: u8) -> u32 {
///
/// Reads the VMG (Video Manager) to discover title sets, then reads each
/// VTS IFO to extract PGC chains, cell addresses, and stream attributes.
pub fn parse_vmg(reader: &mut dyn SectorReader, udf: &UdfFs) -> Result<DvdInfo> {
pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo> {
let vmg_data = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO")?;
// Validate VMG magic
@@ -263,7 +263,7 @@ pub fn parse_vmg(reader: &mut dyn SectorReader, udf: &UdfFs) -> Result<DvdInfo>
///
/// `titles_info` is a list of (chapter_count, vts_title_number) from TT_SRPT.
fn parse_vts(
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
udf: &UdfFs,
vts_number: u8,
titles_info: &[(u16, u8)],
+246
View File
@@ -0,0 +1,246 @@
//! Bounded-syscall primitive: run a (potentially-blocking) operation
//! on a worker thread, with a hard wall-clock deadline and an optional
//! cooperative [`Halt`] poll. The calling thread is never trapped
//! inside a kernel call.
//!
//! ## Why this exists
//!
//! [`crate::halt::Halt`] is cooperative: callers poll
//! `is_cancelled()`. It cannot reach inside a syscall the kernel
//! currently owns the thread for — `libc::sync_file_range`,
//! `libc::fsync`, `File::write` on NFS, and so on. `/api/stop` from
//! autorip therefore can't unstick a thread sitting in such a syscall.
//!
//! [`bounded_syscall`] is the escape hatch: it runs `op` on a fresh
//! worker thread, then `recv_timeout`s on a rendezvous channel for the
//! result. The wait is broken into ~250 ms slices so the calling
//! thread can poll the supplied [`Halt`] in between. If the deadline
//! elapses or the halt fires, the worker is intentionally leaked — the
//! syscall will unwind whenever the kernel decides, or at process
//! exit, but the caller is free to fall back to a degraded code path
//! (skip the sync, log loudly, etc.).
//!
//! ## Trade-offs
//!
//! - **Thread per call.** Cheap (`std::thread::spawn` is < 100 µs on
//! Linux/macOS), but not free. Use on coarse-grained finalisation
//! syscalls (`sync_all`, `sync_file_range(WAIT_AFTER)`), not on hot
//! inner-loop writes.
//! - **Leak on timeout.** A wedged syscall keeps a kernel slot and a
//! user-space thread around for the rest of the process's life.
//! Bounded by the number of independent rip/mux sessions, which is
//! one per disc. The alternative — trapping the caller forever —
//! defeats the entire purpose of `/api/stop`.
//! - **Halt granularity ~250 ms.** Halt observation is not instant;
//! it's the worst-case latency of the `recv_timeout` slice. Good
//! enough for human-driven stop requests; not suitable for hard
//! real-time deadlines.
//!
//! ## Single source of truth
//!
//! Do NOT inline this pattern. Every blocking-syscall wrapper in the
//! rip + mux pipeline calls this helper, so changes (e.g. swapping the
//! channel impl, adjusting the poll slice, adding metrics) land in one
//! place.
//!
//! ## Platform
//!
//! Pure `std::thread` + `std::sync::mpsc`. No `cfg(target_os)` needed
//! here — the helper itself is platform-agnostic. Callers that wrap
//! Linux-only syscalls (`sync_file_range`) still need their own
//! `#[cfg(target_os = "linux")]` gates; this helper does not.
use std::sync::mpsc::{RecvTimeoutError, sync_channel};
use std::thread;
use std::time::{Duration, Instant};
use crate::halt::Halt;
/// Granularity of the halt poll. The receive loop wakes every
/// [`POLL_INTERVAL`] to (a) check the [`Halt`] token, then (b) check
/// the overall deadline, then go back to waiting. 250 ms is a
/// pragmatic balance: short enough that human-driven `/api/stop` feels
/// responsive (< 0.5 s p99), long enough that the polling overhead is
/// negligible against multi-second syscalls.
const POLL_INTERVAL: Duration = Duration::from_millis(250);
/// Failure outcome from a bounded syscall wrapper.
#[derive(Debug)]
pub(crate) enum BoundedError {
/// The user-visible halt token fired during the wait. The worker
/// thread is intentionally leaked — the caller should fall back to
/// a degraded code path rather than waiting on the syscall to
/// return.
Halted,
/// The deadline elapsed before the syscall returned. Same leak
/// semantics as `Halted`.
Timeout,
/// The worker thread panicked, or its sender disconnected before
/// sending a result. Treat as a benign no-op (callers usually
/// log and continue) rather than a hard error — by definition no
/// syscall observably ran to completion in this case.
WorkerLost,
}
/// Run a (potentially-blocking) operation on a worker thread with a
/// deadline and an optional cooperative halt-token poll. Returns the
/// operation's result if it completes within `timeout`; otherwise one
/// of [`BoundedError::Halted`] / [`BoundedError::Timeout`] /
/// [`BoundedError::WorkerLost`].
///
/// On `Halted` / `Timeout` the worker thread is intentionally leaked:
/// the syscall will unwind whenever the kernel decides, or when the
/// process exits. The calling thread is never trapped inside a kernel
/// call.
///
/// `halt` is polled at [`POLL_INTERVAL`] granularity. Pass `None` for
/// callers that don't (yet) have a halt token plumbed through —
/// behaviour degrades to deadline-only, matching the 0.20.5
/// `wait_after_with_timeout` shape this helper generalises.
///
/// `op` returns `R: Send + 'static`. The closure must own everything
/// it touches because it may outlive this call (timeout / halt cases).
pub(crate) fn bounded_syscall<F, R>(
halt: Option<&Halt>,
timeout: Duration,
op: F,
) -> Result<R, BoundedError>
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
// Rendezvous channel: the worker sends exactly one value (the
// op's return) and then exits. Capacity-0 means the send blocks
// until we receive — fine on the happy path; on the timeout /
// halt path the receiver is dropped and the worker's send
// returns Err, which the worker ignores.
let (tx, rx) = sync_channel::<R>(0);
let _ = thread::Builder::new()
.name("freemkv-bounded-syscall".into())
.spawn(move || {
// Ignore the send error: if we time out (or get halted)
// before the worker finishes, the receiver is dropped
// and `tx.send` returns Err. Either way, the worker has
// nothing more to do.
let _ = tx.send(op());
});
let deadline = Instant::now() + timeout;
loop {
let now = Instant::now();
let remaining = deadline.saturating_duration_since(now);
let slice = remaining.min(POLL_INTERVAL);
match rx.recv_timeout(slice) {
Ok(v) => return Ok(v),
Err(RecvTimeoutError::Timeout) => {
if let Some(h) = halt {
if h.is_cancelled() {
return Err(BoundedError::Halted);
}
}
if Instant::now() >= deadline {
return Err(BoundedError::Timeout);
}
// Otherwise: another slice.
}
Err(RecvTimeoutError::Disconnected) => {
// Worker thread spawn failed, or it panicked before
// sending. Caller treats this as "no syscall ran" —
// typically a no-op + log.
return Err(BoundedError::WorkerLost);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn op_completes_quickly() {
let r = bounded_syscall(None, Duration::from_secs(2), || 42u32);
assert!(matches!(r, Ok(42)));
}
#[test]
fn op_exceeds_timeout() {
// Op sleeps longer than the deadline → Timeout.
let r = bounded_syscall(None, Duration::from_millis(300), || {
thread::sleep(Duration::from_secs(2));
0u32
});
assert!(matches!(r, Err(BoundedError::Timeout)));
}
#[test]
fn halt_fires_during_wait() {
let halt = Halt::new();
let halt2 = halt.clone();
// Flip the halt from a side thread after ~300 ms — long
// enough that the receive loop has rolled at least one
// 250 ms slice and is sitting in `recv_timeout` again when
// the bit flips.
thread::spawn(move || {
thread::sleep(Duration::from_millis(300));
halt2.cancel();
});
let r = bounded_syscall(Some(&halt), Duration::from_secs(5), || {
thread::sleep(Duration::from_secs(5));
0u32
});
assert!(matches!(r, Err(BoundedError::Halted)));
}
#[test]
fn worker_panics() {
// Worker panics → sender drops without sending → recv sees
// Disconnected → WorkerLost. We use an explicit panic in the
// op closure rather than `panic!()` from inside the channel
// machinery; the spawned thread's panic is contained (no
// process abort) because we don't `.join()` it.
let r = bounded_syscall(None, Duration::from_secs(2), || -> u32 {
panic!("intentional test panic");
});
assert!(matches!(r, Err(BoundedError::WorkerLost)));
}
#[test]
fn halt_already_set_before_call_still_returns_halted() {
// Halt observed on the very first poll slice. The op blocks
// forever; we must not wait the full timeout to notice the
// halt is already set.
let halt = Halt::new();
halt.cancel();
let started = Instant::now();
let r = bounded_syscall(Some(&halt), Duration::from_secs(10), || {
thread::sleep(Duration::from_secs(10));
0u32
});
assert!(matches!(r, Err(BoundedError::Halted)));
// Should bail out within ~1 s; allow 2 s of slack for slow
// CI hosts.
assert!(
started.elapsed() < Duration::from_secs(2),
"halt-already-set took {:?}",
started.elapsed()
);
}
#[test]
fn ok_path_takes_no_halt_token() {
// Sanity: the `None` halt path is the documented zero-config
// form (matches the 0.20.5 `wait_after_with_timeout`
// behaviour). Op returns immediately; we must observe Ok.
let flag = Arc::new(AtomicBool::new(false));
let f2 = flag.clone();
let r = bounded_syscall(None, Duration::from_secs(2), move || {
f2.store(true, Ordering::Relaxed);
"ok"
});
assert!(matches!(r, Ok("ok")));
assert!(flag.load(Ordering::Relaxed));
}
}
+5 -2
View File
@@ -14,6 +14,7 @@
//! used by sweep, patch, and mux to overlap reads with writes via a
//! bounded channel + dedicated consumer thread.
pub(crate) mod bounded;
mod writeback;
mod writeback_file;
@@ -27,5 +28,7 @@ pub(crate) use writeback_file::WritebackFile;
// caller — the targeted `#[allow]` keeps the re-export visible without
// dragging the rest of the module under `dead_code`.
#[allow(unused_imports)]
pub use pipeline::WRITE_THROUGH_DEPTH;
pub use pipeline::{DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink};
pub use pipeline::{
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
WRITE_THROUGH_DEPTH,
};
+80 -20
View File
@@ -27,15 +27,11 @@
//! - Consumer panic is converted into
//! `Error::IoError { source: io::Error::other(...) }`.
//!
//! ## Dead-code suppression
//! ## Debug logging
//!
//! Both `Disc::sweep` (`DEFAULT_PIPELINE_DEPTH`, `spawn_named`,
//! `try_send`) and `Disc::patch` (`WRITE_THROUGH_DEPTH`, `Flow::Stop`)
//! are now in-tree callers (0.18 round 2). The targeted `#[allow]`
//! markers below cover the corners of the API not exercised by either
//! call site (e.g. `Pipeline::spawn` without a name; `Flow::Stop`
//! return path from sweep's sink — sweep always consumes the producer's
//! full work-list). Drop the allows once mux migrates.
//! Set `FREEMKV_DEBUG=1` environment variable to enable verbose debug
//! logging throughout the pipeline (channel sends/receives, backpressure,
//! consumer lag detection). This is critical for diagnosing stalls.
use std::io;
use std::sync::mpsc::{SyncSender, sync_channel};
@@ -43,20 +39,29 @@ use std::thread::{self, JoinHandle};
use crate::error::Error;
/// Check if verbose debug logging is enabled via FREEMKV_DEBUG env var.
pub fn debug_enabled() -> bool {
std::env::var("FREEMKV_DEBUG")
.ok()
.map(|v| v == "1" || v == "true" || v == "yes")
.unwrap_or(false)
}
/// Default channel depth for callers without a specific reason to
/// pick another value.
///
/// Empirically tuned for sweep and mux — both want enough slack that
/// short consumer stalls don't immediately back up onto the producer,
/// but not so much that a producer outpacing the consumer accumulates
/// arbitrary buffered work. `4` matches the depth sweep has used
/// since 0.17.11 (originally in `disc/sweep_pipeline.rs`, now in
/// `disc/sweep.rs::SweepSink`). Patch should usually use
/// [`WRITE_THROUGH_DEPTH`] (`1`) instead — write-through gives clean
/// back-pressure between every read attempt and the matching write,
/// which matters when the consumer is updating the mapfile in lockstep.
/// pick another value. Kept conservative (4) — most callers should
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
/// Read pipeline depth. Larger buffer compensates for drive variability
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
/// consumer blocks on write.
pub const READ_PIPELINE_DEPTH: usize = 32;
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
/// sync_file_range blocks; prevents producer from accumulating too much
/// work while consumer waits for NFS to drain.
pub const WRITE_PIPELINE_DEPTH: usize = 16;
/// Channel depth for write-through pipelines. Each `send` fully
/// drains before the next can enqueue. Use this when the producer
/// must observe consumer side-effects (e.g. mapfile state) before
@@ -149,6 +154,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
let mut stopped = false;
while let Ok(item) = rx.recv() {
if debug_enabled() {
tracing::debug!("Pipeline receive: item={}", std::any::type_name::<I>());
}
let apply_start = std::time::Instant::now();
if first_err.is_some() || stopped {
// Drain remaining items so the producer never
// blocks on a dead receiver. `apply` is not
@@ -159,11 +170,32 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
Ok(Flow::Continue) => {}
Ok(Flow::Stop) => {
stopped = true;
if debug_enabled() {
tracing::debug!("Pipeline: consumer returned Flow::Stop");
}
}
Err(e) => {
if debug_enabled() {
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
}
first_err = Some(e);
}
}
let apply_elapsed = apply_start.elapsed();
if debug_enabled() && apply_elapsed > std::time::Duration::from_millis(100) {
tracing::debug!(
"Pipeline apply: took {:.2}s, item={}",
apply_elapsed.as_secs_f64(),
std::any::type_name::<I>()
);
} else if debug_enabled() {
tracing::debug!(
"Pipeline apply: OK in {:.3}ms, item={}",
apply_elapsed.as_micros(),
std::any::type_name::<I>()
);
}
}
match first_err {
@@ -188,7 +220,35 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
/// independent signal (e.g. `Halt`) — `send` alone is not the
/// notification edge.
pub fn send(&self, item: I) -> Result<(), I> {
self.tx.send(item).map_err(|e| e.0)
let start = std::time::Instant::now();
match self.tx.send(item) {
Ok(()) => {
let elapsed = start.elapsed();
if debug_enabled() && elapsed > std::time::Duration::from_millis(10) {
tracing::debug!(
"Pipeline send: blocked {:.2}s, item={}",
elapsed.as_secs_f64(),
std::any::type_name::<I>()
);
} else if debug_enabled() {
tracing::debug!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
}
Ok(())
}
Err(e) => {
let elapsed = start.elapsed();
if debug_enabled() && elapsed > std::time::Duration::from_millis(10) {
tracing::debug!(
"Pipeline send: blocked {:.2}s before channel closed, item={}",
elapsed.as_secs_f64(),
std::any::type_name::<I>()
);
} else if debug_enabled() {
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
}
Err(e.0)
}
}
}
/// Non-blocking variant of [`Pipeline::send`]. If the channel is
+281 -31
View File
@@ -14,9 +14,63 @@
//! a full chunk's worth of work to flush — the wait is near-instant.
//! Dirty cache stays bounded at ~2 × `chunk_bytes` and writes drain
//! continuously instead of in bursts.
//!
//! The chunk size is adaptive: we measure the elapsed time of the
//! `WAIT_AFTER` call over a rolling window of the last 16 chunks and
//! resize the chunk based on the p95. Slow storage (NFS, network
//! shares, HDD) sees larger chunks to amortise per-chunk overhead;
//! fast storage (NVMe) sees smaller chunks to keep cache pressure
//! tight. Bounds: [4 MiB, 256 MiB].
//!
//! ## NFS escape hatch
//!
//! `sync_file_range(WAIT_AFTER)` on an NFS-mounted file can block
//! indefinitely waiting for the server's commit ack. If the server
//! never acks (network partition, server-side hang, slow commit), the
//! syscall never returns and the consumer thread is stuck inside the
//! kernel — `/api/stop` can't reach it because halt is cooperative.
//!
//! When `fstatfs` reports the file lives on an NFS mount
//! (`f_type == NFS_SUPER_MAGIC`), the pipeline skips the WAIT_AFTER +
//! `posix_fadvise(DONTNEED)` dance entirely. NFS clients have their
//! own buffering and commit semantics that handle dirty-page bounds
//! without us forcing the issue. The async `SYNC_FILE_RANGE_WRITE`
//! kickoff still runs (non-blocking by spec) so writeback still gets
//! a nudge.
//!
//! ## Defence in depth: WAIT_AFTER timeout
//!
//! Even on local storage, a degraded disk or odd filesystem driver
//! could in principle wedge inside WAIT_AFTER. Each WAIT_AFTER call
//! runs on a worker thread with a 30s recv_timeout on its result
//! channel. On timeout we log a loud error, set a `degraded` flag,
//! and from then on skip WAIT_AFTER + DONTNEED for the rest of the
//! pipeline's life (same shape as the NFS path). The worker thread
//! is intentionally leaked — it unwinds whenever the syscall
//! eventually returns or the process exits. The mux continues; the
//! original dirty-burst pathology re-emerges but the rip can still
//! finish instead of freezing.
use std::collections::VecDeque;
use std::fs::File;
use std::os::unix::io::{AsRawFd, RawFd};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
const ADAPTIVE_WINDOW: usize = 16;
const CHUNK_BYTES_MIN: u64 = 4 * 1024 * 1024;
const CHUNK_BYTES_MAX: u64 = 256 * 1024 * 1024;
const ADAPTIVE_GROW_MS: u64 = 200;
const ADAPTIVE_SHRINK_MS: u64 = 20;
/// Every N chunks, emit a `debug!` snapshot of the current chunk
/// size so operators tailing the log can see where the autoscaler
/// settled.
const SIZE_LOG_INTERVAL: u64 = 32;
/// Hard upper bound on a single `sync_file_range(WAIT_AFTER)` call.
/// Beyond this we declare the pipeline degraded and stop calling
/// WAIT_AFTER for the rest of its life.
const WAIT_AFTER_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) struct WritebackPipeline {
/// Aliases the wrapping `WritebackFile::file`. Only valid for the
@@ -28,6 +82,25 @@ pub(crate) struct WritebackPipeline {
chunk_bytes: u64,
last_flush_pos: u64,
pending: Option<(u64, u64)>,
/// Rolling window of recent `WAIT_AFTER` elapsed_ms measurements.
wait_after_window: VecDeque<u64>,
/// Count of chunks emitted (used to space out periodic
/// `debug!` size snapshots).
chunk_count: u64,
/// True when the underlying file is on an NFS mount. NFS makes
/// WAIT_AFTER unsafe (can block forever on missing server ack), so
/// we skip it entirely and let the NFS client handle commit on
/// close.
is_nfs: bool,
/// Set the first time WAIT_AFTER exceeds [`WAIT_AFTER_TIMEOUT`].
/// Once set, behaviour matches the NFS path for the rest of the
/// pipeline's life. Wrapped in `Arc` only because both this
/// struct and the spawned worker thread (which itself doesn't
/// touch the flag) share-via-fd patterns might one day need it;
/// today it's effectively a single-owner cell — the `Arc` shape
/// keeps the door open for moving the read side into a worker
/// without re-plumbing types.
degraded: Arc<AtomicBool>,
}
impl WritebackPipeline {
@@ -36,14 +109,33 @@ impl WritebackPipeline {
/// itself, or kept inside the same struct that owns `file` — the
/// alias is unchecked.
pub(crate) fn new(file: &File, start_pos: u64, chunk_bytes: u64) -> Self {
let fd = file.as_raw_fd();
let is_nfs = detect_nfs(fd);
tracing::info!(
target: "mux",
"WritebackPipeline fd={fd} is_nfs={is_nfs} chunk_bytes={chunk_bytes} strategy={}",
if is_nfs { "nfs-skip-wait" } else { "wait+dontneed" }
);
Self {
fd: file.as_raw_fd(),
fd,
chunk_bytes,
last_flush_pos: start_pos,
pending: None,
wait_after_window: VecDeque::with_capacity(ADAPTIVE_WINDOW),
chunk_count: 0,
is_nfs,
degraded: Arc::new(AtomicBool::new(false)),
}
}
/// True if we should bypass the WAIT_AFTER + DONTNEED finalisation
/// step. NFS always bypasses; local storage bypasses once the
/// pipeline has flipped to degraded after a WAIT_AFTER timeout.
#[inline]
fn skip_wait(&self) -> bool {
self.is_nfs || self.degraded.load(Ordering::Relaxed)
}
/// Caller advanced the file position to `pos`. If a chunk boundary
/// was crossed, kick async writeback for the just-completed chunk
/// and finalise the previous one.
@@ -51,27 +143,115 @@ impl WritebackPipeline {
if pos < self.last_flush_pos.saturating_add(self.chunk_bytes) {
return;
}
let chunk_off = self.last_flush_pos as i64;
let chunk_len = (pos - self.last_flush_pos) as i64;
let mut wait_ms: u64 = 0;
let mut fadvise_ms: u64 = 0;
// Async kickoff for the just-completed chunk runs on every
// path (NFS, degraded, normal) — it's nominally non-blocking
// by spec and gives the kernel an early hint that this range
// is ready to flush.
unsafe {
let chunk_off = self.last_flush_pos as i64;
let chunk_len = (pos - self.last_flush_pos) as i64;
libc::sync_file_range(self.fd, chunk_off, chunk_len, libc::SYNC_FILE_RANGE_WRITE);
if let Some((prev_off, prev_len)) = self.pending.take() {
libc::sync_file_range(
self.fd,
prev_off as i64,
prev_len as i64,
libc::SYNC_FILE_RANGE_WAIT_AFTER,
);
libc::posix_fadvise(
self.fd,
prev_off as i64,
prev_len as i64,
libc::POSIX_FADV_DONTNEED,
);
}
self.pending = Some((chunk_off as u64, chunk_len as u64));
}
if let Some((prev_off, prev_len)) = self.pending.take() {
if self.skip_wait() {
// NFS branch (or degraded fallback after a prior
// timeout): the WAIT_AFTER + DONTNEED dance is what
// hangs on NFS — skip it. We still advance `pending`
// so the next call has a stable cycle.
} else {
// Normal local-storage branch with belt-and-braces
// timeout. If WAIT_AFTER hangs > WAIT_AFTER_TIMEOUT
// we mark the pipeline degraded, log a loud error,
// and fall through to the skip path on subsequent
// calls.
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
Some(ms) => {
wait_ms = ms;
let t_fadv = Instant::now();
unsafe {
libc::posix_fadvise(
self.fd,
prev_off as i64,
prev_len as i64,
libc::POSIX_FADV_DONTNEED,
);
}
fadvise_ms = t_fadv.elapsed().as_millis() as u64;
self.record_wait(wait_ms);
}
None => {
// Timeout branch: switch to NFS-style skip
// for the rest of the pipeline's life. Do
// NOT call DONTNEED — if WAIT_AFTER hasn't
// returned, the pages aren't safely flushed.
self.degraded.store(true, Ordering::Relaxed);
tracing::error!(
target: "mux",
"WritebackPipeline WAIT_AFTER timed out after {}s on chunk off={} len={}, marking writeback degraded (subsequent chunks will skip WAIT_AFTER + DONTNEED)",
WAIT_AFTER_TIMEOUT.as_secs(),
prev_off,
prev_len
);
}
}
}
}
self.pending = Some((chunk_off as u64, chunk_len as u64));
self.last_flush_pos = pos;
self.chunk_count += 1;
tracing::trace!(
target: "mux",
"WritebackPipeline chunk off={} len={} sync_file_range_ms={wait_ms} fadvise_ms={fadvise_ms} chunk_bytes={} skip_wait={}",
chunk_off,
chunk_len,
self.chunk_bytes,
self.skip_wait(),
);
if self.chunk_count % SIZE_LOG_INTERVAL == 0 {
tracing::debug!(
target: "mux",
"WritebackPipeline chunk_bytes={} after {} chunks is_nfs={} degraded={}",
self.chunk_bytes,
self.chunk_count,
self.is_nfs,
self.degraded.load(Ordering::Relaxed),
);
}
}
/// Push a new `WAIT_AFTER` measurement into the rolling window
/// and, if the window is full, adapt `chunk_bytes` based on p95.
fn record_wait(&mut self, wait_ms: u64) {
if self.wait_after_window.len() == ADAPTIVE_WINDOW {
self.wait_after_window.pop_front();
}
self.wait_after_window.push_back(wait_ms);
if self.wait_after_window.len() < ADAPTIVE_WINDOW {
return;
}
// p95 of 16 samples ≈ sorted[14] (5 % of 16 = 0.8 ≈ 1 above).
let mut sorted: Vec<u64> = self.wait_after_window.iter().copied().collect();
sorted.sort_unstable();
let p95 = sorted[14];
let old = self.chunk_bytes;
let new = if p95 > ADAPTIVE_GROW_MS && self.chunk_bytes < CHUNK_BYTES_MAX {
(self.chunk_bytes * 2).min(CHUNK_BYTES_MAX)
} else if p95 < ADAPTIVE_SHRINK_MS && self.chunk_bytes > CHUNK_BYTES_MIN {
(self.chunk_bytes / 2).max(CHUNK_BYTES_MIN)
} else {
self.chunk_bytes
};
if new != old {
self.chunk_bytes = new;
tracing::info!(
target: "mux",
"WritebackPipeline adaptive chunk_bytes {} -> {} p95_ms={p95}",
old,
new
);
}
}
/// Caller is about to seek away from the current write region.
@@ -85,20 +265,90 @@ impl WritebackPipeline {
/// or when discarding the pipeline.
pub(crate) fn finalize(&mut self) {
if let Some((prev_off, prev_len)) = self.pending.take() {
unsafe {
libc::sync_file_range(
self.fd,
prev_off as i64,
prev_len as i64,
libc::SYNC_FILE_RANGE_WAIT_AFTER,
);
libc::posix_fadvise(
self.fd,
prev_off as i64,
prev_len as i64,
libc::POSIX_FADV_DONTNEED,
);
tracing::debug!(
target: "mux",
"WritebackPipeline finalize chunk off={prev_off} len={prev_len} skip_wait={} is_nfs={} degraded={}",
self.skip_wait(),
self.is_nfs,
self.degraded.load(Ordering::Relaxed),
);
if self.skip_wait() {
// NFS / degraded: skip WAIT_AFTER + DONTNEED. close()
// / sync_all() handle commit through their normal
// paths.
return;
}
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
Some(_ms) => unsafe {
libc::posix_fadvise(
self.fd,
prev_off as i64,
prev_len as i64,
libc::POSIX_FADV_DONTNEED,
);
},
None => {
self.degraded.store(true, Ordering::Relaxed);
tracing::error!(
target: "mux",
"WritebackPipeline finalize WAIT_AFTER timed out after {}s on chunk off={prev_off} len={prev_len}, marking writeback degraded",
WAIT_AFTER_TIMEOUT.as_secs(),
);
}
}
}
}
}
/// Probe whether `fd` lives on an NFS mount via `fstatfs`. Returns
/// `false` on any error — we fail open, not closed: better to run the
/// normal local-storage path on a misdetected NFS mount (and surface
/// the freeze loudly via the timeout) than to needlessly disable
/// writeback bounding on every local file because of a transient
/// stat error.
fn detect_nfs(fd: RawFd) -> bool {
// `libc::statfs` is repr(C) with a fixed layout; zeroing is the
// documented init pattern for the kernel uapi struct.
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::fstatfs(fd, &mut buf) };
if rc != 0 {
let errno = std::io::Error::last_os_error();
tracing::warn!(
target: "mux",
"WritebackPipeline fstatfs(fd={fd}) failed: {errno} — defaulting is_nfs=false",
);
return false;
}
// `f_type` is signed (`__fsword_t`) on glibc and unsigned
// (`c_ulong`) on musl. Cast both sides to i64 for a portable
// comparison.
let f_type = buf.f_type as i64;
let nfs_magic = libc::NFS_SUPER_MAGIC as i64;
f_type == nfs_magic
}
/// Run `sync_file_range(WAIT_AFTER)` on a worker thread and wait up
/// to [`WAIT_AFTER_TIMEOUT`] for it to return. `Some(elapsed_ms)` on
/// success; `None` on timeout. On timeout the worker thread is
/// 0.20.6 generalizes the worker-thread + recv_timeout pattern into
/// [`crate::io::bounded::bounded_syscall`]; this helper now just adapts
/// the generic primitive to the WAIT_AFTER call shape (returns elapsed_ms
/// instead of the syscall's `()` return, treats `WorkerLost` as a benign
/// no-op to match the original semantics).
fn wait_after_with_timeout(fd: RawFd, off: u64, len: u64) -> Option<u64> {
let started = Instant::now();
match crate::io::bounded::bounded_syscall(None, WAIT_AFTER_TIMEOUT, move || unsafe {
libc::sync_file_range(fd, off as i64, len as i64, libc::SYNC_FILE_RANGE_WAIT_AFTER);
}) {
Ok(()) => Some(started.elapsed().as_millis() as u64),
Err(crate::io::bounded::BoundedError::Timeout)
| Err(crate::io::bounded::BoundedError::Halted) => None,
Err(crate::io::bounded::BoundedError::WorkerLost) => {
// Worker thread spawn failed or panicked before sending.
// Treat as a benign success (no syscall ran) rather than
// a degrade trigger — falling through with elapsed_ms=0
// matches the no-op behaviour.
Some(0)
}
}
}
+103 -3
View File
@@ -27,7 +27,7 @@ use std::path::Path;
use super::writeback::WritebackPipeline;
const CHUNK_BYTES: u64 = 32 * 1024 * 1024;
const WRITEBACK_CHUNK_BYTES: u64 = 32 * 1024 * 1024;
pub(crate) struct WritebackFile {
file: File,
@@ -42,7 +42,7 @@ impl WritebackFile {
/// or appended files).
pub(crate) fn new(mut file: File) -> io::Result<Self> {
let pos = file.stream_position()?;
let pipeline = WritebackPipeline::new(&file, pos, CHUNK_BYTES);
let pipeline = WritebackPipeline::new(&file, pos, WRITEBACK_CHUNK_BYTES);
Ok(Self {
file,
pipeline,
@@ -54,11 +54,57 @@ impl WritebackFile {
/// and wrap it. Convenience for the common
/// `File::create(path)` + `WritebackFile::new(file)` pair so callers
/// don't have to assemble a `File` first.
///
/// Callers that know the target output size should prefer
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
/// extents.
#[allow(dead_code)]
pub(crate) fn create(path: &Path) -> io::Result<Self> {
let file = File::create(path)?;
Self::new(file)
}
/// Like [`Self::create`] but pre-reserves `size_bytes` of disk
/// space via `fallocate(FALLOC_FL_KEEP_SIZE)` on Linux. The
/// reported file size is unchanged (writes still grow the file
/// naturally) — only the on-disk extent allocation is preallocated,
/// which reduces extent fragmentation on large sequential writes
/// (mux output, especially on slow storage / NFS).
///
/// On macOS / Windows the size hint is ignored and this is
/// equivalent to `create`.
pub(crate) fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
let file = File::create(path)?;
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
// FALLOC_FL_KEEP_SIZE = 0x01 — keep the reported file
// size at 0 (writes grow it normally) while still
// pre-reserving the extents.
let rc = unsafe {
libc::fallocate(
file.as_raw_fd(),
libc::FALLOC_FL_KEEP_SIZE,
0,
size_bytes as i64,
)
};
tracing::debug!(
target: "mux",
"WritebackFile fallocate size_hint={size_bytes} rc={rc} ok={}",
rc == 0
);
}
#[cfg(not(target_os = "linux"))]
{
tracing::debug!(
target: "mux",
"WritebackFile fallocate size_hint={size_bytes} skipped (non-linux)"
);
}
Self::new(file)
}
/// Open an existing file at `path` for writing (no truncation) and
/// wrap it. Mirrors `File::open` semantics for the writable case
/// — used by patch / resume paths that mutate an existing ISO in
@@ -70,9 +116,49 @@ impl WritebackFile {
/// Drain in-flight writeback then issue a full fsync. Use this in
/// place of `File::sync_all`.
///
/// The final fsync is wrapped in
/// [`crate::io::bounded::bounded_syscall`] with a 60 s deadline.
/// fsync on a wedged NFS server (or a degraded local disk) can
/// hang the calling thread; the wrapper ensures the worst case is
/// 60 s + log-and-continue rather than indefinite. On timeout the
/// page cache is left to the kernel's normal flush-on-close path —
/// best effort, but bounded.
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
self.pipeline.finalize();
self.file.sync_all()
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
use std::time::Duration;
let fd = self.file.as_raw_fd();
match crate::io::bounded::bounded_syscall(
None,
Duration::from_secs(60),
move || -> io::Result<()> {
let rc = unsafe { libc::fsync(fd) };
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
},
) {
Ok(inner) => inner,
Err(crate::io::bounded::BoundedError::Timeout) => {
tracing::error!(
target: "mux",
"WritebackFile::sync_all fsync timed out after 60s; kernel will flush on close (best-effort)"
);
Ok(())
}
Err(crate::io::bounded::BoundedError::Halted) => Ok(()),
Err(crate::io::bounded::BoundedError::WorkerLost) => Ok(()),
}
}
#[cfg(not(unix))]
{
self.file.sync_all()
}
}
}
@@ -104,6 +190,20 @@ impl Seek for WritebackFile {
// before every write, and we don't want that to drain the
// pipeline on every iteration.
if p != self.pos {
// Diagnostic for the NFS 73 % mux hang: the MKV format
// requires the muxer to seek back occasionally (cluster
// size patching, Cues index write, Segment header
// backpatch). Each such seek invalidates the writeback
// chunk tracking and forces a finalize → WAIT_AFTER on
// the in-flight chunk. Logging the seek delta lets us
// correlate hang offsets with specific muxer operations.
let from_pos = self.pos;
let to_pos = p;
let delta: i64 = (to_pos as i64).wrapping_sub(from_pos as i64);
tracing::debug!(
target: "mux",
"WritebackFile seek from={from_pos} to={to_pos} delta={delta}"
);
self.pipeline.handle_seek(p);
self.pos = p;
}
+402
View File
@@ -0,0 +1,402 @@
//! BDMV disc-library metadata (`/BDMV/META/DL/bdmt_<lang>.xml`).
//!
//! Every commercial Blu-ray carries a disc-library metadata directory
//! with one XML file per shipped language. The schema is the Blu-ray
//! "disc library metadata" namespace (`urn:BDA:bdmv;disclibmeta`),
//! conventionally prefixed `di:`. Fields commonly present:
//!
//! - `<di:title>` or `<di:name>` — the title string. Vendor practice
//! varies (Paramount discs tend to use `<di:name>`).
//! - `<di:description>` — optional synopsis (often absent on retail
//! discs; common on box sets and special editions).
//! - `<di:discNumber>` / `<di:numSets>` (or `<di:numberOfSets>`) —
//! set position for multi-disc releases.
//!
//! This module is intentionally separate from the BD-J `StreamLabel`
//! parsers under `labels/*.rs`. The XML here is disc-level (title,
//! description, set position), not per-stream — wiring into the main
//! parser registry happens elsewhere.
//!
//! Real-world XML is irregular: missing description elements, multiple
//! title elements (first one wins), and occasional malformed content.
//! Extraction is best-effort — a malformed file is treated as "no
//! metadata" (returns `None` from the helper), and the caller can
//! still get metadata from sibling-language XML files.
// The module wiring (registry hook + public re-export) is added
// separately. Until then the parse/detect entry points have no
use super::xml;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::BTreeMap;
/// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`.
///
/// All maps are keyed by 3-char ISO 639-2 language code (e.g.
/// `"eng"`, `"fra"`, `"jpn"`) — the same key segment used in the
/// `bdmt_<lang>.xml` filename.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
pub struct DiscMetadata {
/// Localized titles, keyed by 3-char ISO 639-2 lang code
/// (e.g. "eng" → "Dune Part Two")
pub titles: BTreeMap<String, String>,
/// First-line / short description, per lang
pub descriptions: BTreeMap<String, String>,
/// Disc N of M for box sets (None if not a box set)
pub disc_number: Option<(u32, u32)>,
}
/// True if `/BDMV/META/DL/` exists and contains at least one
/// `bdmt_*.xml` file.
pub fn detect(udf: &UdfFs) -> bool {
let Some(dir) = udf.find_dir("/BDMV/META/DL") else {
return false;
};
dir.entries
.iter()
.any(|e| !e.is_dir && is_bdmt_filename(&e.name))
}
/// Read every `bdmt_<lang>.xml` under `/BDMV/META/DL/` and return the
/// aggregated [`DiscMetadata`]. Returns `None` if no titles could be
/// extracted from any file.
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata> {
let dir = udf.find_dir("/BDMV/META/DL")?;
let mut out = DiscMetadata::default();
for entry in &dir.entries {
if entry.is_dir {
continue;
}
let Some(lang) = lang_code_from_filename(&entry.name) else {
continue;
};
let path = format!("/BDMV/META/DL/{}", entry.name);
let Ok(bytes) = udf.read_file(reader, &path) else {
continue;
};
let Ok(text) = std::str::from_utf8(&bytes) else {
continue;
};
let Some((title, description, disc_set)) = parse_bdmt_xml(&lang, text) else {
continue;
};
out.titles.insert(lang.clone(), title);
if let Some(desc) = description {
out.descriptions.insert(lang.clone(), desc);
}
// Disc-set position is disc-global; first one we successfully
// read wins. (All bdmt_*.xml on a given disc carry the same
// value in practice.)
if out.disc_number.is_none() {
if let Some(ds) = disc_set {
out.disc_number = Some(ds);
}
}
}
if out.titles.is_empty() {
None
} else {
Some(out)
}
}
/// True if `name` matches the `bdmt_<lang>.xml` convention with a
/// 3-character ISO 639-2 lang code segment. Case-insensitive.
fn is_bdmt_filename(name: &str) -> bool {
lang_code_from_filename(name).is_some()
}
/// Extract the 3-char language code from a `bdmt_<lang>.xml` filename.
/// Returns `None` if the filename doesn't match. Lang code is
/// lowercased so callers always see e.g. `"eng"` not `"ENG"`.
fn lang_code_from_filename(name: &str) -> Option<String> {
let lower = name.to_ascii_lowercase();
let stem = lower.strip_suffix(".xml")?;
let lang = stem.strip_prefix("bdmt_")?;
// ISO 639-2 codes are exactly 3 ASCII letters. Be strict — keeps
// us from picking up unrelated `bdmt_foo.xml` siblings.
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
Some(lang.to_string())
}
/// Tuple returned by [`parse_bdmt_xml`]: `(title, description?, disc_set?)`.
/// Aliased so the function signature isn't a clippy::type-complexity offender.
pub(crate) type BdmtFields = (String, Option<String>, Option<(u32, u32)>);
/// Parse one `bdmt_<lang>.xml` document and return
/// `(title, description?, disc_set?)`. Returns `None` if no title
/// could be located — the caller treats this as "skip this file".
///
/// Title-element preference: `<di:name>` → `<di:title>` →
/// `<di:tableOfContents>/<di:titleName>` (first match wins, per the
/// authoring-tool conventions documented at the module level).
pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option<BdmtFields> {
let title = extract_title(xml_text)?;
let description = xml::text(xml_text, "description")
.filter(|s| !s.is_empty())
.filter(|s| !looks_like_xml(s))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let disc_set = extract_disc_set(xml_text);
Some((title, description, disc_set))
}
/// Reject candidate description strings that are themselves XML
/// fragments — observed on disc-04 (Top Gun: Maverick), where
/// `<di:description>` contained `<di:thumbnail href="…"/>` child
/// elements and no actual prose. Surfacing that raw to the JSON
/// output is worse than dropping the field entirely.
fn looks_like_xml(s: &str) -> bool {
let t = s.trim_start();
t.starts_with('<')
}
/// Try title-bearing element variants in priority order. The `xml`
/// helpers are case- and namespace-insensitive, so callers pass the
/// bare local name (no `di:` prefix).
fn extract_title(xml_text: &str) -> Option<String> {
// Order matches the module-level convention: <di:name> first
// (Paramount-style), then <di:title>, then the nested
// tableOfContents/titleName form.
for tag in ["name", "title"] {
if let Some(s) = xml::text(xml_text, tag) {
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
// tableOfContents/titleName: search inside the toc block so we
// don't accidentally pick a stray <titleName> from elsewhere.
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
let block = &xml_text[s..e];
if let Some(t) = xml::text(block, "titleName") {
let trimmed = t.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
/// Extract `(discNumber, numSets)` if both are present and parse as
/// `u32`. Accepts either `<di:numSets>` or `<di:numberOfSets>` for
/// the denominator (both forms appear in the wild).
fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> {
let n = xml::text(xml_text, "discNumber")?
.trim()
.parse::<u32>()
.ok()?;
let total = xml::text(xml_text, "numSets")
.or_else(|| xml::text(xml_text, "numberOfSets"))?
.trim()
.parse::<u32>()
.ok()?;
Some((n, total))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_simple_title() {
// Minimal Paramount-style document: <di:name> as the title
// carrier inside a <discInfo> root.
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Part Two</di:name>
</discInfo>"#;
let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse");
assert_eq!(title, "Dune Part Two");
assert_eq!(desc, None);
assert_eq!(set, None);
}
#[test]
fn extract_title_element_variant() {
// <di:title> is the alternate carrier; should be picked up
// when <di:name> is absent.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:title>The Matrix</di:title>
<di:description>A film about computers.</di:description>
</discInfo>"#;
let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "The Matrix");
assert_eq!(desc.as_deref(), Some("A film about computers."));
}
#[test]
fn extract_title_from_table_of_contents_fallback() {
// Some authoring tools nest the title under tableOfContents.
// No <di:name> or <di:title> at top level → fall back to
// titleName inside tableOfContents.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:tableOfContents>
<di:titleName>Inside Out 2</di:titleName>
</di:tableOfContents>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "Inside Out 2");
}
#[test]
fn extract_box_set_position() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>LOTR Disc 2</di:name>
<di:discNumber>2</di:discNumber>
<di:numSets>5</di:numSets>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(set, Some((2, 5)));
}
#[test]
fn extract_box_set_position_alternate_total_tag() {
// <di:numberOfSets> is an alternate spelling we've seen.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>3</di:discNumber>
<di:numberOfSets>6</di:numberOfSets>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(set, Some((3, 6)));
}
#[test]
fn extract_box_set_requires_both_fields() {
// discNumber alone (no total) yields None — we don't fabricate
// a denominator.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>1</di:discNumber>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(set, None);
}
#[test]
fn multiple_languages_keyed_correctly() {
// Simulate driving parse_bdmt_xml from two synthetic XML
// blobs and aggregating into DiscMetadata the same way parse()
// would. This exercises the BTreeMap key handling without
// needing a UdfFs.
let eng_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Part Two</di:name>
</discInfo>"#;
let fra_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Deuxième Partie</di:name>
<di:description>Suite du film de 2021.</di:description>
</discInfo>"#;
let mut meta = DiscMetadata::default();
for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] {
let (title, desc, ds) = parse_bdmt_xml(lang, blob).unwrap();
meta.titles.insert(lang.to_string(), title);
if let Some(d) = desc {
meta.descriptions.insert(lang.to_string(), d);
}
if meta.disc_number.is_none() {
if let Some(d) = ds {
meta.disc_number = Some(d);
}
}
}
assert_eq!(
meta.titles.get("eng").map(String::as_str),
Some("Dune Part Two")
);
assert_eq!(
meta.titles.get("fra").map(String::as_str),
Some("Dune Deuxième Partie")
);
assert!(meta.descriptions.get("eng").is_none());
assert_eq!(
meta.descriptions.get("fra").map(String::as_str),
Some("Suite du film de 2021.")
);
assert_eq!(meta.disc_number, None);
}
#[test]
fn malformed_xml_returns_none() {
// Random gibberish has no recognizable title element. We
// document the contract: parse_bdmt_xml returns None, and
// parse() (the caller) skips the file. Aggregating across
// zero files leaves DiscMetadata::default() — which parse()
// surfaces as None to its caller. Either is documented as
// acceptable per the module spec.
let bad = "this is not xml &&& <<< nope";
assert!(parse_bdmt_xml("eng", bad).is_none());
// Half-open tag, no body, no close: also yields no title.
let truncated = "<discInfo><di:name>";
assert!(parse_bdmt_xml("eng", truncated).is_none());
}
#[test]
fn description_with_only_child_xml_is_dropped() {
// Real-world bug from disc-04 (Top Gun: Maverick, 2026-05-11
// capture): <di:description> contained only <di:thumbnail/>
// child elements with no actual prose. The previous parser
// surfaced the raw XML fragment as the description string.
// Now we reject candidates that begin with `<`.
let xml = r#"<discInfo>
<di:name>Top Gun: Maverick</di:name>
<di:description>
<di:thumbnail href="tgm_meta_sm.jpg" />
<di:thumbnail href="tgm_meta_lg.jpg" />
</di:description>
</discInfo>"#;
let (title, description, _) =
parse_bdmt_xml("eng", xml).expect("title is present so parse must succeed");
assert_eq!(title, "Top Gun: Maverick");
assert!(
description.is_none(),
"description containing only XML children must be dropped, got {description:?}"
);
}
#[test]
fn description_with_plain_text_passes_through() {
// The legitimate case still works: a description with actual
// prose survives the looks_like_xml filter.
let xml = r#"<discInfo>
<di:name>Some Movie</di:name>
<di:description>An epic tale of one man's quest for tea.</di:description>
</discInfo>"#;
let (_, description, _) = parse_bdmt_xml("eng", xml).expect("must parse");
assert_eq!(
description.as_deref(),
Some("An epic tale of one man's quest for tea.")
);
}
#[test]
fn whitespace_in_title_is_trimmed() {
let xml = r#"<discInfo><di:name>
Dune Part Two
</di:name></discInfo>"#;
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "Dune Part Two");
}
#[test]
fn lang_code_extraction() {
assert_eq!(lang_code_from_filename("bdmt_eng.xml"), Some("eng".into()));
assert_eq!(lang_code_from_filename("BDMT_FRA.XML"), Some("fra".into()));
assert_eq!(lang_code_from_filename("bdmt_jpn.xml"), Some("jpn".into()));
// Non-matching cases:
assert_eq!(lang_code_from_filename("bdmt_.xml"), None);
assert_eq!(lang_code_from_filename("bdmt_engl.xml"), None);
assert_eq!(lang_code_from_filename("bdmt_e1g.xml"), None);
assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None);
assert_eq!(lang_code_from_filename("foo.xml"), None);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! CLPI vs MPLS cross-validation diagnostic.
//!
//! Empirical question (raised 2026-05-11): is CLPI's per-stream
//! language and codec data truly redundant with MPLS's STN-table data
//! on real-world Blu-rays?
//!
//! Build a quick audit that walks both sources, normalizes their stream
//! lists by (PID, language, coding_type), and flags any disagreement.
//!
//! Three classes of mismatch we want to detect:
//!
//! 1. **CLPI has streams MPLS doesn't reference.** Orphan streams in
//! the .m2ts that no playlist's STN table includes. Means the user
//! can't reach them through the menu but they're physically on the
//! disc.
//! 2. **MPLS has streams CLPI doesn't list.** Should never happen if
//! both parsers are correct — playlists reference clips which
//! reference streams. If it happens, one of our parsers has a bug.
//! 3. **Same PID, different language / coding_type.** The playlist re-
//! tagged a stream's metadata. Rare but spec-permitted. Means CLPI
//! and MPLS disagree about the same physical stream's properties.
//!
//! If audits across the corpus show zero mismatches of any class, CLPI
//! program_info extraction is **empirically redundant** for labels and
//! we can leave it out of the registry. If even one mismatch surfaces,
//! we add a CLPI label parser to the registry as belt-and-suspenders.
//!
//! This module exposes `audit(reader, udf)` returning a structured
//! report. Surfaced via the labels-analyze tool — not part of the
//! `analyze()` pipeline (no impact on the label output).
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::BTreeMap;
/// One row in the audit: a stream PID that's known to one source or
/// both, with the fields each source reported.
#[derive(Debug, Clone)]
pub struct ClpiVsMplsRow {
pub pid: u16,
pub clpi_coding_type: Option<u8>,
pub clpi_language: Option<String>,
pub mpls_coding_type: Option<u8>,
pub mpls_language: Option<String>,
}
impl ClpiVsMplsRow {
/// Three rules for classification:
/// - both sources missing (impossible — caller wouldn't insert)
/// - one source missing → class A or B (orphan-on-disc / playlist-only)
/// - both present but fields differ → class C (metadata divergence)
/// - both present and identical → no mismatch
pub fn class(&self) -> ClpiVsMplsClass {
match (
self.clpi_coding_type.is_some(),
self.mpls_coding_type.is_some(),
) {
(true, false) => ClpiVsMplsClass::ClpiOnly,
(false, true) => ClpiVsMplsClass::MplsOnly,
(true, true) => {
let coding_match = self.clpi_coding_type == self.mpls_coding_type;
let lang_match = self.clpi_language == self.mpls_language;
if coding_match && lang_match {
ClpiVsMplsClass::Match
} else {
ClpiVsMplsClass::Divergent
}
}
(false, false) => ClpiVsMplsClass::Match,
}
}
}
/// Classification of one (PID) row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClpiVsMplsClass {
/// PID seen in CLPI ProgramInfo but no MPLS STN table references
/// it. Orphan on disc.
ClpiOnly,
/// PID seen in MPLS STN table but no CLPI ProgramInfo includes it.
/// One of our parsers probably has a bug.
MplsOnly,
/// Both sources see this PID with the same coding_type + language.
Match,
/// Both sources see this PID but disagree on coding_type or language.
/// MPLS wins for label rendering (playlist-authoritative view); CLPI
/// is the per-clip ground truth.
Divergent,
}
/// Full audit report.
#[derive(Debug, Clone, Default)]
pub struct ClpiVsMplsAudit {
pub rows: Vec<ClpiVsMplsRow>,
}
impl ClpiVsMplsAudit {
pub fn class_counts(&self) -> (usize, usize, usize, usize) {
let mut clpi_only = 0;
let mut mpls_only = 0;
let mut matches = 0;
let mut divergent = 0;
for r in &self.rows {
match r.class() {
ClpiVsMplsClass::ClpiOnly => clpi_only += 1,
ClpiVsMplsClass::MplsOnly => mpls_only += 1,
ClpiVsMplsClass::Match => matches += 1,
ClpiVsMplsClass::Divergent => divergent += 1,
}
}
(clpi_only, mpls_only, matches, divergent)
}
}
/// Walk `/BDMV/CLIPINF/*.clpi` and `/BDMV/PLAYLIST/*.mpls`, build a
/// dedup-by-PID table of (CLPI fields, MPLS fields), return the
/// merged view. Missing files (read errors, parse failures) are
/// silently skipped — this is diagnostic, not correctness-critical.
pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit {
// Aggregate by PID across all CLPI files. If a PID appears in
// multiple clips (typical — main movie clip + trailers reference
// the same audio stream PIDs), first encountered wins (they should
// all agree per BD spec).
let mut clpi_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new();
if let Some(dir) = udf.find_dir("/BDMV/CLIPINF") {
let names: Vec<String> = dir
.entries
.iter()
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".clpi"))
.map(|e| e.name.clone())
.collect();
for name in names {
let path = format!("/BDMV/CLIPINF/{}", name);
let Ok(data) = udf.read_file(reader, &path) else {
continue;
};
let Ok(clip) = crate::clpi::parse(&data) else {
continue;
};
for s in clip.streams {
clpi_by_pid
.entry(s.pid)
.or_insert((s.coding_type, s.language));
}
}
}
// Same for MPLS streams.
let mut mpls_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new();
if let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") {
let names: Vec<String> = dir
.entries
.iter()
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".mpls"))
.map(|e| e.name.clone())
.collect();
for name in names {
let path = format!("/BDMV/PLAYLIST/{}", name);
let Ok(data) = udf.read_file(reader, &path) else {
continue;
};
let Ok(pl) = crate::mpls::parse(&data) else {
continue;
};
for s in pl.streams {
if s.pid == 0 {
// PID 0 means "no PID in stream entry" — skip rather
// than collide with other entries.
continue;
}
mpls_by_pid
.entry(s.pid)
.or_insert((s.coding_type, s.language));
}
}
}
// Merge views: every PID seen anywhere gets a row.
let mut all_pids: std::collections::BTreeSet<u16> = std::collections::BTreeSet::new();
all_pids.extend(clpi_by_pid.keys().copied());
all_pids.extend(mpls_by_pid.keys().copied());
let mut rows = Vec::with_capacity(all_pids.len());
for pid in all_pids {
let clpi = clpi_by_pid.get(&pid);
let mpls = mpls_by_pid.get(&pid);
rows.push(ClpiVsMplsRow {
pid,
clpi_coding_type: clpi.map(|(c, _)| *c),
clpi_language: clpi.map(|(_, l)| l.clone()),
mpls_coding_type: mpls.map(|(c, _)| *c),
mpls_language: mpls.map(|(_, l)| l.clone()),
});
}
ClpiVsMplsAudit { rows }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn class_match_when_identical() {
let r = ClpiVsMplsRow {
pid: 0x1100,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("eng".into()),
};
assert_eq!(r.class(), ClpiVsMplsClass::Match);
}
#[test]
fn class_clpi_only_when_mpls_missing() {
let r = ClpiVsMplsRow {
pid: 0x1100,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: None,
mpls_language: None,
};
assert_eq!(r.class(), ClpiVsMplsClass::ClpiOnly);
}
#[test]
fn class_mpls_only_when_clpi_missing() {
let r = ClpiVsMplsRow {
pid: 0x1100,
clpi_coding_type: None,
clpi_language: None,
mpls_coding_type: Some(0x90),
mpls_language: Some("fra".into()),
};
assert_eq!(r.class(), ClpiVsMplsClass::MplsOnly);
}
#[test]
fn class_divergent_on_lang_disagreement() {
let r = ClpiVsMplsRow {
pid: 0x1100,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("und".into()),
};
assert_eq!(r.class(), ClpiVsMplsClass::Divergent);
}
#[test]
fn class_counts_sum_rows() {
let audit = ClpiVsMplsAudit {
rows: vec![
ClpiVsMplsRow {
pid: 0x1100,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("eng".into()),
}, // Match
ClpiVsMplsRow {
pid: 0x1101,
clpi_coding_type: Some(0x83),
clpi_language: Some("fra".into()),
mpls_coding_type: None,
mpls_language: None,
}, // ClpiOnly
ClpiVsMplsRow {
pid: 0x1102,
clpi_coding_type: None,
clpi_language: None,
mpls_coding_type: Some(0x90),
mpls_language: Some("eng".into()),
}, // MplsOnly
ClpiVsMplsRow {
pid: 0x1103,
clpi_coding_type: Some(0x86),
clpi_language: Some("spa".into()),
mpls_coding_type: Some(0x86),
mpls_language: Some("ita".into()),
}, // Divergent
],
};
let (co, mo, m, d) = audit.class_counts();
assert_eq!(co, 1);
assert_eq!(mo, 1);
assert_eq!(m, 1);
assert_eq!(d, 1);
}
}
+2 -2
View File
@@ -4,7 +4,7 @@
//! stream number mapping via playbackconfig.
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::HashMap;
@@ -12,7 +12,7 @@ pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "streamproperties.xml")
}
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult> {
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?;
let sp_text = std::str::from_utf8(&sp_data).ok()?;
+5 -5
View File
@@ -5,7 +5,7 @@
//! menu_base.prop provides stream number → button name mapping.
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::HashMap;
@@ -14,7 +14,7 @@ pub fn detect(udf: &UdfFs) -> bool {
|| super::jar_file_exists(udf, "language_streams.txt")
}
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult> {
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
// Try language_streams.txt first (richer structured data)
let ls_labels = parse_language_streams(reader, udf);
@@ -55,7 +55,7 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
// ── language_streams.txt parser ────────────────────────────────────────────
fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(reader, udf, "language_streams.txt")?;
let text = std::str::from_utf8(&data).ok()?;
@@ -184,7 +184,7 @@ mod tests {
/// Build a minimal menu_base.prop text and run `parse_menu_base`'s
/// inner logic via a temporary closure. This isolates the prop
/// parsing without needing a SectorReader.
/// parsing without needing a SectorSource.
fn parse_props(text: &str) -> Vec<StreamLabel> {
// Mirror the inner loop of parse_menu_base exactly. Kept
// separate so the test doesn't need disc fixtures.
@@ -338,7 +338,7 @@ mod tests {
// ── menu_base.prop parser ──────────────────────────────────────────────────
fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(reader, udf, "menu_base.prop")?;
let text = std::str::from_utf8(&data).ok()?;
+3 -3
View File
@@ -35,11 +35,11 @@
use super::class_reader::CpInfo;
use super::{ParseResult, StreamLabel, StreamLabelType, jar, vocab};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::BTreeMap;
/// dbp detect can't peek inside a jar without a SectorReader (the
/// dbp detect can't peek inside a jar without a SectorSource (the
/// trait function only takes `&UdfFs`), so we trigger on the cheap
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
/// BD-J disc, but parse() does the real `com/dbp/` check and
@@ -49,7 +49,7 @@ pub fn detect(udf: &UdfFs) -> bool {
jar::has_any_top_level_jar(udf)
}
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult> {
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
jar::for_each_jar(reader, udf, |_entry_name, archive| {
if !jar::has_path_prefix(archive, "com/dbp/") {
return None;
+209 -112
View File
@@ -52,7 +52,7 @@
//! (each enum value's `<init>` is called with its name string as
//! the first arg).
//!
//! - **Phase C** — binding-class identification (`find_binding_class`).
//! - **Phase C** — binding-class identification (`find_binding_classes`).
//! The per-stream table is built by some class via repeated
//! `getstatic` references to the master enums identified in A.
//! That class has the highest such `getstatic` count in the jar.
@@ -84,18 +84,18 @@ use super::class_reader::{
ICONST_2, ICONST_3, ICONST_4, ICONST_5, ICONST_M1, INVOKESPECIAL, LDC, LDC_W, NEW, SIPUSH,
};
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, jar, vocab};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::{HashMap, HashSet};
pub fn detect(udf: &UdfFs) -> bool {
// Cheap pre-check at the dir level; the real signal is
// `com/bydeluxe/` inside any top-level jar's central directory,
// which `parse()` confirms when given a `SectorReader`.
// which `parse()` confirms when given a `SectorSource`.
jar::has_any_top_level_jar(udf)
}
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult> {
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
jar::for_each_jar(reader, udf, |entry_name, archive| {
if !jar::has_path_prefix(archive, "com/bydeluxe/") {
return None;
@@ -138,34 +138,41 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult>
);
}
// Phase C — find the binding class via getstatic count to
// the master enums.
let binding_class_name = find_binding_class(archive, &master_table.class_name_set());
let Some(binding_class_name) = binding_class_name else {
// Phase C — find ALL binding-class candidates (audio + subtitle
// are often split across two classes on Deluxe). Each gets its
// own `<clinit>` walk; constructions union into a single
// stream list for interpret_streams.
let binding_classes = find_binding_classes(archive, &master_table.class_name_set());
if binding_classes.is_empty() {
tracing::info!(
jar = %entry_name,
"deluxe: no binding class found (no class has enough getstatic refs to master enums)"
);
return None;
};
tracing::info!(
jar = %entry_name,
binding_class = %binding_class_name,
"deluxe binding class identified",
);
}
for (name, count) in &binding_classes {
tracing::info!(
jar = %entry_name,
binding_class = %name,
getstatic_count = count,
"deluxe binding class candidate",
);
}
// Phase D — decode the binding class's <clinit>.
let streams = decode_binding(archive, &binding_class_name, &master_table);
// Phase D — decode each binding class's <clinit>.
let mut streams: Vec<Construction> = Vec::new();
for (name, _) in &binding_classes {
streams.extend(decode_binding(archive, name, &master_table));
}
if streams.is_empty() {
tracing::info!(
jar = %entry_name,
binding_class = %binding_class_name,
"deluxe: binding class found but produced 0 decoded streams"
"deluxe: binding classes found but produced 0 decoded streams"
);
return None;
}
let labels = interpret_streams(&streams, &codec_table, &master_table);
let labels = interpret_streams(&streams, &master_table);
if labels.is_empty() {
return None;
}
@@ -545,26 +552,39 @@ fn clinit_news_and_ldcs(
/// arity); we use a low floor (4) so a small disc with few streams
/// still qualifies, but high enough to filter out classes that just
/// reference the language enum once for a config string.
pub(crate) fn find_binding_class(
/// Identify all binding-class candidates by getstatic-count to the
/// master enums. Some Deluxe discs split the per-stream table across
/// two binding classes (one for audio, one for subtitle), so the
/// per-stream decoder needs to walk all of them. Returns top-K
/// candidates ordered by descending getstatic count, filtered to a
/// minimum concentration of master-enum references.
///
/// Empirically (POC v0.3 dumps): on disc-01 the audio binding class
/// (`ma.class`) has ~82 getstatic refs, the subtitle binding
/// (`ko.class`) has ~63. Both share the master Language + Purpose
/// enums.
pub(crate) fn find_binding_classes(
archive: &mut jar::Jar,
master_enum_classes: &HashSet<&str>,
) -> Option<String> {
) -> Vec<(String, usize)> {
const MIN_GETSTATIC: usize = 4;
let mut best: Option<(String, usize)> = None;
let mut candidates: Vec<(String, usize)> = Vec::new();
jar::for_each_class(archive, |class_name, class| {
let count = count_master_enum_getstatic(class, master_enum_classes);
if count < MIN_GETSTATIC {
return;
}
match &best {
None => best = Some((class_name.to_string(), count)),
Some((_, c)) if count > *c => {
best = Some((class_name.to_string(), count));
}
_ => {}
if count >= MIN_GETSTATIC {
candidates.push((class_name.to_string(), count));
}
});
best.map(|(name, _)| name)
candidates.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
// Top candidates only — anything significantly below the top one
// is noise. We keep candidates whose count is at least 40% of the
// top, capped at 4 total (audio + subtitle + future use).
if let Some(top_count) = candidates.first().map(|(_, c)| *c) {
let threshold = (top_count * 2) / 5; // 40%
candidates.retain(|(_, c)| *c >= threshold);
candidates.truncate(4);
}
candidates
}
/// Count `getstatic` instructions in this class's `<clinit>` whose
@@ -617,6 +637,15 @@ pub(crate) enum StackVal {
kind: &'static str,
ordinal: u16,
},
/// Reference to a `org.bluray.ti.CodingType` enum value. Field
/// name (e.g. `DOLBY_AC3_AUDIO`, `DOLBY_LOSSLESS_AUDIO`) is the
/// codec identifier. Deluxe binding constructors take a
/// `LCodingType;` arg directly — codecs are NOT a Deluxe-internal
/// enum (Phase B's codec-subclass walk was based on a wrong
/// assumption; the actual codec source is the standard BD-J API
/// enum). Discovered via deluxe-poc v0.3 binding-bytecode dump
/// against disc-01 (Disney) + disc-09 (Warner) on 2026-05-10.
CodingType(String),
/// An uninitialized `new` object — popped by the matching
/// invokespecial.
NewObj(String),
@@ -626,6 +655,10 @@ pub(crate) enum StackVal {
Unknown,
}
/// Fully-qualified class name of the BD-J spec codec enum that
/// Deluxe constructors reference directly.
const BD_CODING_TYPE_CLASS: &str = "org/bluray/ti/CodingType";
/// Phase D entry point: find the binding class in `archive`, run the
/// bytecode walker against its `<clinit>`, return one `Construction`
/// per `new X / invokespecial X.<init>` sequence.
@@ -755,10 +788,19 @@ impl<'a> BindingDecoder<'a> {
let val = insn
.cp_index()
.and_then(|i| self.pool.member_ref(i))
.and_then(|m| {
self.master
.resolve(m.class_name, m.name)
.map(|(kind, ord)| StackVal::EnumRef { kind, ordinal: ord })
.map(|m| {
// Three-way resolution:
// 1. org.bluray.ti.CodingType.X → CodingType(X)
// 2. master-enum classname.X → EnumRef(kind, ord)
// 3. anything else → Unknown
if m.class_name == BD_CODING_TYPE_CLASS {
StackVal::CodingType(m.name.to_string())
} else if let Some((kind, ord)) = self.master.resolve(m.class_name, m.name)
{
StackVal::EnumRef { kind, ordinal: ord }
} else {
StackVal::Unknown
}
})
.unwrap_or(StackVal::Unknown);
self.stack.push(val);
@@ -946,48 +988,37 @@ impl MasterEnumTable {
// ── interpret_streams: Constructions → StreamLabels ─────────────────────────
/// Convert the per-construction tuples from Phase D into
/// [`StreamLabel`]s using a documented heuristic.
/// [`StreamLabel`]s. Pattern verified against corpus discs via
/// deluxe-poc v0.3 binding-bytecode dump (2026-05-10):
///
/// Heuristic:
/// - For each Construction, classify the args into language / codec /
/// purpose / region / int slots using the EnumRef kinds.
/// - A construction with a Language ref AND a Codec ref → Audio stream.
/// - A construction with a Language ref AND no Codec ref → Subtitle.
/// - Any construction with no Language ref → ignored (not a stream).
/// - stream_number = sequential per type (1, 2, 3 ...).
/// - language = vocab::lang fallback applied to the enum value
/// string (so "Brazilian Portuguese" → "por" + variant="Brazilian").
/// When the enum value isn't a recognized phrase, the raw value is
/// used as language verbatim (ISO codes pass through).
/// - codec_hint = the CodecTable lookup of the codec ordinal.
/// - purpose = LabelPurpose decoded from the Purpose-enum value
/// string via the documented Deluxe Purpose enum (Normal,
/// Commentary, PiP, Trivia, Descriptive, Score, NoForced,
/// NoForcedDescriptive).
/// - qualifier = LabelQualifier::Forced when the Purpose-enum value
/// is one of the *Forced* variants (Normal+Forced bit etc.); else
/// None. (Deluxe's framework doesn't appear to expose SDH at this
/// layer — that comes from the subtitle codec.)
/// Disney binding (5-arg): `BindingType.<init>(I, Lbe;, Llp;, I, LCodingType;)V`
/// Warner binding (4-arg): `BindingType.<init>(I, Law;, Lgp;, LCodingType;)V`
///
/// This heuristic is the part of Phase D that needs corpus-disc
/// verification. The DECODING (Constructions from bytecode) is
/// mechanically correct; the SEMANTIC INTERPRETATION here may need
/// adjustment once we see real binding-class output.
fn interpret_streams(
constructions: &[Construction],
codec_table: &CodecTable,
master: &MasterEnumTable,
) -> Vec<StreamLabel> {
/// Args are identified by **TYPE**, not position:
/// - First `EnumRef{kind: "Language"}` → audio/subtitle language
/// - First `EnumRef{kind: "Purpose"}` → Deluxe purpose ordinal
/// - First `CodingType(name)` → codec field name (translated via
/// [`coding_type_to_codec_hint`])
/// - First `Int(n)` → stream index (preserved as ordering hint;
/// per-type sequential stream_number is what actually goes into
/// the StreamLabel, since BD spec stream-numbering is anchored on
/// MPLS data, not the binding code)
///
/// Stream type inference:
/// - Construction has a `CodingType` arg → audio stream (subtitles
/// on Deluxe don't carry a CodingType; their codec is implicit
/// PGS via the BD spec).
/// - Construction has Language but no CodingType → subtitle stream.
/// - No Language → not a stream (skip).
fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -> Vec<StreamLabel> {
let mut audio_idx: u16 = 0;
let mut sub_idx: u16 = 0;
let mut out = Vec::new();
for c in constructions {
// Collect typed args: language / purpose ordinals + any int
// (preserved for future stream-index resolution; logged only
// for now).
let mut lang_ord: Option<u16> = None;
let mut purpose_ord: Option<u16> = None;
let mut coding_type: Option<String> = None;
let mut stream_idx_hint: Option<i32> = None;
for arg in &c.args {
match arg {
@@ -996,31 +1027,28 @@ fn interpret_streams(
"Purpose" => purpose_ord = purpose_ord.or(Some(*ordinal)),
_ => {}
},
StackVal::Int(n) => stream_idx_hint = stream_idx_hint.or(Some(*n)),
StackVal::CodingType(name) => {
coding_type = coding_type.or_else(|| Some(name.clone()));
}
StackVal::Int(n) => {
stream_idx_hint = stream_idx_hint.or(Some(*n));
}
_ => {}
}
}
// The Codec enum isn't fingerprinted by ldc prefix (0 ldcs
// in its <clinit>), so it isn't in MasterEnumTable.
// EnumRefs resolved through master_table never carry the
// "Codec" kind. Codec association at this layer is heuristic:
// if the construction's binding_type matches one of the
// CodecTable entries (substring match), use that codec.
// Stronger codec→ordinal resolution is deferred until corpus
// bytecode confirms the codec field's location in the
// binding constructor's args.
let codec_hint = codec_table
.codecs
.iter()
.find(|name| !name.is_empty() && c.binding_type.contains(name.as_str()))
.cloned()
.unwrap_or_default();
let Some(lang_ord) = lang_ord else { continue };
// Audio when codec is known, subtitle otherwise.
let (stream_type, stream_number) = if !codec_hint.is_empty() {
// Audio when a CodingType is present (audio binding type
// always references org.bluray.ti.CodingType); subtitle
// otherwise.
let codec_hint = coding_type
.as_deref()
.map(coding_type_to_codec_hint)
.map(str::to_string)
.unwrap_or_default();
let (stream_type, stream_number) = if coding_type.is_some() {
audio_idx += 1;
(StreamLabelType::Audio, audio_idx)
} else {
@@ -1044,10 +1072,10 @@ fn interpret_streams(
if let Some(hint) = stream_idx_hint {
tracing::debug!(
stream_idx_hint = hint,
disc_stream_idx = hint,
lang = %language,
binding = %c.binding_type,
"deluxe interpret_streams: captured int arg for future stream-index resolution"
"deluxe interpret_streams: disc-authored stream index (not used for stream_number; preserved for diagnostic)"
);
}
@@ -1066,6 +1094,39 @@ fn interpret_streams(
out
}
/// Map a `org.bluray.ti.CodingType` field name (as observed in
/// getstatic operands on Deluxe binding classes) to a human-readable
/// codec hint string.
///
/// CodingType is the standard BD-J API enum; values are documented
/// in the BD-J specification and verified empirically against the
/// binding-bytecode dumps in `(internal)/research/deluxe-poc/data/`.
/// Unknown field names pass through unchanged so unfamiliar codecs
/// still surface something rather than going silent.
fn coding_type_to_codec_hint(field: &str) -> &str {
match field {
// Lossless / hi-res.
"DOLBY_LOSSLESS_AUDIO" => "Dolby TrueHD",
"DTS_HD_LOSSLESS_AUDIO" | "DTS_HD_MA_AUDIO" => "DTS-HD Master Audio",
"LPCM_AUDIO" => "LPCM",
// Dolby family.
"DOLBY_AC3_AUDIO" => "Dolby Digital",
"DOLBY_DIGITAL_PLUS_AUDIO" => "Dolby Digital Plus",
"DOLBY_ATMOS_AUDIO" => "Dolby Atmos",
// DTS family.
"DTS_AUDIO" => "DTS",
"DTS_HD_AUDIO" | "DTS_HD_HR_AUDIO" => "DTS-HD HR",
// MPEG family.
"MPEG1_AUDIO_LAYER2" | "MPEG2_AUDIO_LAYER2" => "MPEG Audio",
// PG-style subtitle codecs (rare to see in Deluxe bindings;
// subtitles usually have NO CodingType arg).
"PG_STREAM" | "PRESENTATION_GRAPHICS_STREAM" => "PGS",
// Unknown / future — pass through verbatim so the operator
// can see what the disc actually authored.
_ => field,
}
}
/// Deluxe Purpose enum ordinal → (LabelPurpose, LabelQualifier). The
/// enum order is fixed per Phase A's verified output:
/// 0=Normal, 1=Commentary, 2=PiP, 3=Trivia, 4=Descriptive, 5=Score,
@@ -1419,9 +1480,10 @@ mod tests {
}
#[test]
fn interpret_streams_emits_subtitle_when_no_codec() {
// A Construction with just a language enum ref + no codec
// → subtitle stream (codec_hint stays empty).
fn interpret_streams_emits_subtitle_when_no_codingtype() {
// A Construction with just a language enum ref (no CodingType)
// -> subtitle stream (codec_hint stays empty). Subtitles on
// Deluxe don't carry a CodingType arg.
let constructions = vec![Construction {
binding_type: "SubtitleSlot".into(),
args: vec![StackVal::EnumRef {
@@ -1429,9 +1491,7 @@ mod tests {
ordinal: 0,
}],
}];
let codec_table = CodecTable::default();
let master = lang_enum_master();
let out = interpret_streams(&constructions, &codec_table, &master);
let out = interpret_streams(&constructions, &lang_enum_master());
assert_eq!(out.len(), 1);
assert_eq!(out[0].stream_type, StreamLabelType::Subtitle);
assert_eq!(out[0].language, "eng");
@@ -1439,24 +1499,28 @@ mod tests {
}
#[test]
fn interpret_streams_emits_audio_when_binding_matches_codec_name() {
// A Construction whose binding_type contains a codec name
// from CodecTable → audio stream with codec_hint populated.
fn interpret_streams_emits_audio_when_codingtype_present() {
// A Construction with a CodingType arg -> audio stream with
// codec_hint populated by coding_type_to_codec_hint.
let constructions = vec![Construction {
binding_type: "AudioSlot_ATMOS".into(),
args: vec![StackVal::EnumRef {
kind: "Language",
ordinal: 0,
}],
binding_type: "ng".into(),
args: vec![
StackVal::Int(1),
StackVal::EnumRef {
kind: "Language",
ordinal: 0,
},
StackVal::EnumRef {
kind: "Purpose",
ordinal: 0,
},
StackVal::CodingType("DOLBY_LOSSLESS_AUDIO".into()),
],
}];
let codec_table = CodecTable {
codecs: vec!["ATMOS".into(), "DTS".into()],
};
let master = lang_enum_master();
let out = interpret_streams(&constructions, &codec_table, &master);
let out = interpret_streams(&constructions, &lang_enum_master());
assert_eq!(out.len(), 1);
assert_eq!(out[0].stream_type, StreamLabelType::Audio);
assert_eq!(out[0].codec_hint, "ATMOS");
assert_eq!(out[0].codec_hint, "Dolby TrueHD");
assert_eq!(out[0].language, "eng");
}
@@ -1475,7 +1539,7 @@ mod tests {
},
],
}];
let out = interpret_streams(&constructions, &CodecTable::default(), &lang_enum_master());
let out = interpret_streams(&constructions, &lang_enum_master());
assert_eq!(out.len(), 1);
assert_eq!(out[0].purpose, LabelPurpose::Commentary);
}
@@ -1486,10 +1550,43 @@ mod tests {
binding_type: "SomeOtherType".into(),
args: vec![StackVal::Int(1)],
}];
let out = interpret_streams(&constructions, &CodecTable::default(), &lang_enum_master());
let out = interpret_streams(&constructions, &lang_enum_master());
assert!(out.is_empty());
}
#[test]
fn coding_type_maps_known_codecs() {
// BD-J spec CodingType field names -> display strings.
assert_eq!(
coding_type_to_codec_hint("DOLBY_LOSSLESS_AUDIO"),
"Dolby TrueHD"
);
assert_eq!(
coding_type_to_codec_hint("DOLBY_AC3_AUDIO"),
"Dolby Digital"
);
assert_eq!(
coding_type_to_codec_hint("DOLBY_DIGITAL_PLUS_AUDIO"),
"Dolby Digital Plus"
);
assert_eq!(coding_type_to_codec_hint("DTS_AUDIO"), "DTS");
assert_eq!(
coding_type_to_codec_hint("DTS_HD_MA_AUDIO"),
"DTS-HD Master Audio"
);
assert_eq!(coding_type_to_codec_hint("LPCM_AUDIO"), "LPCM");
}
#[test]
fn coding_type_passes_through_unknown() {
// Unknown field names pass through verbatim so the operator
// sees what the disc authored.
assert_eq!(
coding_type_to_codec_hint("FUTURE_CODEC_X"),
"FUTURE_CODEC_X"
);
}
#[test]
fn extract_codec_name_picks_uppercase_with_underscore() {
// Synthetic class file built via ClassFile::parse would be
+3 -3
View File
@@ -13,7 +13,7 @@
#![allow(dead_code)]
use super::class_reader::ClassFile;
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::io::Cursor;
use zip::ZipArchive;
@@ -26,7 +26,7 @@ pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
/// True if `/BDMV/JAR/` contains at least one top-level `.jar` file
/// (not under a subdir). Used by `detect()` in parsers whose real
/// signal lives inside a jar — they can't open the jar without a
/// `SectorReader`, so they use this cheap pre-check and do the real
/// `SectorSource`, so they use this cheap pre-check and do the real
/// `com/<vendor>/` discriminator in `parse()`.
pub fn has_any_top_level_jar(udf: &UdfFs) -> bool {
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
@@ -49,7 +49,7 @@ pub fn has_any_top_level_jar(udf: &UdfFs) -> bool {
///
/// Entries that fail to read from UDF or that aren't valid zips are
/// silently skipped — same defensive shape as the existing dbp parser.
pub fn for_each_jar<R, F>(reader: &mut dyn SectorReader, udf: &UdfFs, mut f: F) -> Option<R>
pub fn for_each_jar<R, F>(reader: &mut dyn SectorSource, udf: &UdfFs, mut f: F) -> Option<R>
where
F: FnMut(&str, &mut Jar) -> Option<R>,
{
+521 -23
View File
@@ -4,15 +4,18 @@
//! To add a new format:
//! 1. Create `src/labels/myformat.rs`
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
//! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
//! 3. Implement `pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
mod bdmt;
pub(crate) mod class_reader;
pub mod clpi_audit;
mod criterion;
mod ctrm;
mod dbp;
mod deluxe;
pub(crate) mod jar;
mod mpls_universal;
mod paramount;
mod pixelogic;
pub(crate) mod text;
@@ -20,9 +23,14 @@ pub mod vocab;
pub(crate) mod xml;
use crate::disc::{DiscTitle, Stream};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
// Re-export bdmt's public type so callers can construct/inspect
// disc-level metadata via `labels::DiscMetadata`. The module itself
// stays private — analyze() drives the parse path.
pub use bdmt::DiscMetadata;
// Re-exported via crate::disc — the public API surfaces these next to
// AudioStream/SubtitleStream so callers can map purpose/qualifier to display
// text in their own locale.
@@ -49,7 +57,7 @@ pub struct StreamLabel {
pub variant: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StreamLabelType {
Audio,
Subtitle,
@@ -79,7 +87,7 @@ pub enum LabelQualifier {
// to array order on confidence ties.
type DetectFn = fn(&UdfFs) -> bool;
type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<ParseResult>;
type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option<ParseResult>;
/// Per-parser claim of how reliable its output is. Used by the
/// registry to pick between parsers when more than one matches (e.g.
@@ -88,10 +96,14 @@ type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<ParseResult>;
/// A parser SHOULD return `High` only when its full schema was
/// extracted with no fallback or guessing. `Medium` is for matched-
/// but-degraded outputs (some streams missing fields, fingerprint
/// matched but a sub-table couldn't be decoded, etc.). The registry
/// prefers `High` over `Medium`; ties fall to array order.
/// matched but a sub-table couldn't be decoded, etc.). `Low` is for
/// the universal MPLS fallback — spec-mandated stream metadata
/// (language + base codec) that's correct but lacks editorial labels
/// (commentary, SDH, etc.). The registry prefers `High > Medium > Low`;
/// ties fall to array order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Confidence {
Low,
Medium,
High,
}
@@ -124,6 +136,16 @@ impl ParseResult {
confidence: Confidence::Medium,
}
}
/// Convenience for the universal MPLS fallback: spec-derived
/// stream language + codec, but no editorial labels (commentary,
/// SDH, etc.). Framework parsers always win over `low`.
pub fn low(labels: Vec<StreamLabel>) -> Self {
ParseResult {
labels,
confidence: Confidence::Low,
}
}
}
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
@@ -138,11 +160,22 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
// (constant-pool iteration vs. deluxe's bytecode walking).
("dbp", dbp::detect, dbp::parse),
("deluxe", deluxe::detect, deluxe::parse),
// Universal MPLS fallback. Returns Confidence::Low so framework
// parsers always win when they match. Closes the "no framework
// matched" gap (e.g. HDMV-only discs) with spec-derived language
// + base codec for every stream the playlist references. Runs
// last in registry order so it's only the chosen parser when
// nothing else fired.
(
"mpls_universal",
mpls_universal::detect,
mpls_universal::parse,
),
];
/// Search disc for config files, extract labels, apply to streams.
/// This is 100% optional — if anything fails, streams are untouched.
pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) {
pub fn apply(reader: &mut dyn SectorSource, udf: &UdfFs, titles: &mut [DiscTitle]) {
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf)))
.unwrap_or_default();
if labels.is_empty() {
@@ -158,7 +191,7 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
/// `forced` flag.
///
/// Extracted from `apply()` so the matching logic is unit-testable
/// without needing a SectorReader / UdfFs.
/// without needing a SectorSource / UdfFs.
pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) {
for title in titles.iter_mut() {
let mut audio_idx: u16 = 0;
@@ -336,7 +369,7 @@ fn generate_audio_label(
}
}
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
let mut best: Option<(&'static str, ParseResult)> = None;
for (name, detect, parse) in PARSERS {
if !detect(udf) {
@@ -357,21 +390,213 @@ fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
_ => {}
}
}
match best {
Some((name, r)) => {
let (name, mut labels) = match best {
Some((n, r)) => {
tracing::info!(
parser = name,
parser = n,
confidence = ?r.confidence,
label_count = r.labels.len(),
"label parser selected",
);
r.labels
(n, r.labels)
}
None => {
tracing::info!("no label parser matched");
Vec::new()
return Vec::new();
}
};
// Gap-fill: framework parsers often under-yield on multi-track
// discs because their authoring layer only ships editorial labels
// for "interesting" streams (Director's Cut, Atmos, SDH) and
// leaves the rest as plain numbered slots. MPLS sees all streams
// the playlist references. If MPLS has entries the framework
// didn't cover (by stream_type + stream_number), merge them in
// so the user sees every track even when only the "interesting"
// ones have editorial names. Skips the merge when mpls_universal
// was itself the chosen parser (its labels ARE the labels).
if name != "mpls_universal" {
if let Some(mpls_result) = mpls_universal::parse(reader, udf) {
fill_gaps_from_mpls(&mut labels, &mpls_result.labels);
}
}
// CLPI orphan streams: PIDs in /BDMV/CLIPINF/*.clpi ProgramInfo
// that no MPLS playlist references. Empirical (2026-05-11): ~5%
// of streams across the 11-disc corpus are CLPI-only — physically
// on disc, not menu-reachable. Append them as Low-confidence
// labels at the tail of each stream_type (next slot after the
// highest existing stream_number).
let _orphans_added = append_clpi_orphans(&mut labels, reader, udf);
labels
}
/// Append entries from `mpls` whose `(stream_type, stream_number)`
/// isn't already represented in `framework`. Framework labels are
/// richer (editorial purpose/qualifier, codec_hint with object-audio
/// detail like Atmos) so they always win for slots they cover; MPLS
/// only fills in untaken slots. Stable sort by (type, number) at
/// the end so callers see a deterministic, ascending list.
fn fill_gaps_from_mpls(framework: &mut Vec<StreamLabel>, mpls: &[StreamLabel]) {
use std::collections::HashSet;
let covered: HashSet<(StreamLabelType, u16)> = framework
.iter()
.map(|l| (l.stream_type, l.stream_number))
.collect();
let mut added = 0usize;
for m in mpls {
if !covered.contains(&(m.stream_type, m.stream_number)) {
framework.push(m.clone());
added += 1;
}
}
if added > 0 {
tracing::info!(
gap_fill_added = added,
"MPLS gap-fill merged streams the framework parser left uncovered"
);
framework.sort_by_key(|l| (type_tag(l.stream_type), l.stream_number));
}
}
/// Stable sort key for `StreamLabelType`. Audio < Subtitle so the
/// merged label list groups audios first then subtitles.
fn type_tag(t: StreamLabelType) -> u8 {
match t {
StreamLabelType::Audio => 0,
StreamLabelType::Subtitle => 1,
}
}
/// Append CLPI ProgramInfo streams that NO existing label covers by
/// PID. These are "orphan" streams — physically present in the .m2ts
/// per CLPI's clip-authoritative view, but no MPLS playlist references
/// them, so the framework + MPLS gap-fill missed them. Returns the
/// number of orphans appended.
///
/// Numbering: the new entries get `stream_number = max(existing
/// per type) + 1, +2, …` so the playlist-reachable streams keep their
/// original positions and orphans sort cleanly at the tail. Empirically
/// these are commentary or alternate-version streams that the
/// authoring tool left out of the published playlist.
fn append_clpi_orphans(
labels: &mut Vec<StreamLabel>,
reader: &mut dyn SectorSource,
udf: &UdfFs,
) -> usize {
// Index existing labels by PID — but StreamLabel doesn't carry
// PID. Index by (type, language, codec_hint) tuple instead; this
// is fuzzier than PID matching but the only signal available
// here. False positives (a CLPI orphan that happens to share
// (type, lang, codec) with an MPLS stream we already have) are
// benign — we just skip the duplicate. False negatives (rare)
// would cause double-listing, which is the conservative failure
// mode.
use std::collections::HashSet;
let existing: HashSet<(StreamLabelType, String, String)> = labels
.iter()
.map(|l| (l.stream_type, l.language.clone(), l.codec_hint.clone()))
.collect();
// Walk CLPI files, collect distinct (type, pid, coding_type, lang)
// tuples not already in `existing`. Dedup by PID across files so
// a stream appearing in two clips only gets added once.
let Some(dir) = udf.find_dir("/BDMV/CLIPINF") else {
return 0;
};
let names: Vec<String> = dir
.entries
.iter()
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".clpi"))
.map(|e| e.name.clone())
.collect();
let mut seen_pids: HashSet<u16> = HashSet::new();
let mut candidates: Vec<(StreamLabelType, u16, u8, String)> = Vec::new();
for name in names {
let path = format!("/BDMV/CLIPINF/{}", name);
let Ok(data) = udf.read_file(reader, &path) else {
continue;
};
let Ok(clip) = crate::clpi::parse(&data) else {
continue;
};
for s in clip.streams {
if !seen_pids.insert(s.pid) {
continue;
}
// Translate CLPI coding_type → label stream_type.
let stype = match s.coding_type {
0x80..=0x86 | 0xA1 | 0xA2 => StreamLabelType::Audio,
0x90 | 0x91 => StreamLabelType::Subtitle,
_ => continue, // video / unknown — skip
};
// Same dedup logic as MPLS: normalize language, build codec
// hint, check against existing label set.
let lang_norm = s.language.trim().to_ascii_lowercase();
let codec_hint = mpls_universal::codec_name(s.coding_type).to_string();
if existing.contains(&(stype, lang_norm.clone(), codec_hint.clone())) {
continue;
}
candidates.push((stype, s.pid, s.coding_type, lang_norm));
}
}
if candidates.is_empty() {
return 0;
}
// Find next available stream_number per type.
let mut next_audio: u16 = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.map(|l| l.stream_number)
.max()
.unwrap_or(0)
+ 1;
let mut next_sub: u16 = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
.map(|l| l.stream_number)
.max()
.unwrap_or(0)
+ 1;
let added = candidates.len();
for (stype, _pid, coding_type, language) in candidates {
let codec_hint = mpls_universal::codec_name(coding_type).to_string();
let name = mpls_universal::language_display_name(&language);
let stream_number = match stype {
StreamLabelType::Audio => {
let n = next_audio;
next_audio += 1;
n
}
StreamLabelType::Subtitle => {
let n = next_sub;
next_sub += 1;
n
}
};
labels.push(StreamLabel {
stream_number,
stream_type: stype,
language,
name,
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
codec_hint,
variant: String::new(),
});
}
if added > 0 {
tracing::info!(
clpi_orphans_added = added,
"CLPI-only streams appended (PIDs not referenced by any MPLS playlist)"
);
labels.sort_by_key(|l| (type_tag(l.stream_type), l.stream_number));
}
added
}
/// Diagnostic introspection — returns the parser that matched, the
@@ -382,7 +607,7 @@ fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
/// the return shape is richer (includes confidence, all detected
/// parsers, and any parsers that produced empty results).
#[doc(hidden)]
pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis {
pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis {
let inventory = jar_inventory(udf);
let mut parsers_detected: Vec<&'static str> = Vec::new();
let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new();
@@ -412,11 +637,29 @@ pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis {
.then(std::cmp::Ordering::Equal)
});
let (parser, confidence, labels) = match chosen {
let (parser, confidence, mut labels) = match chosen {
Some((name, r)) => (Some(*name), Some(r.confidence), r.labels.clone()),
None => (None, None, Vec::new()),
};
// Gap-fill: same merge as `extract()`. Framework labels (when
// present) win for the slots they cover; MPLS fills in uncovered
// stream_numbers. Skipped when MPLS was itself the chosen parser
// (no gaps to fill against itself).
let gap_fill_added = if parser.is_some() && parser != Some("mpls_universal") {
let before = labels.len();
// Re-run MPLS unconditionally — we only ran framework parsers
// above (we want to know which one to pick), and in the
// common case where MPLS would have detected but wasn't
// chosen we still need its labels for the merge.
if let Some(mpls_result) = mpls_universal::parse(reader, udf) {
fill_gaps_from_mpls(&mut labels, &mpls_result.labels);
}
labels.len().saturating_sub(before)
} else {
0
};
if parsers_detected.is_empty() {
tracing::info!("no label parser matched");
} else if parser.is_none() {
@@ -426,15 +669,79 @@ pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis {
);
}
// bdmt runs independently of the parser registry: it's disc-level
// metadata (localized titles, box-set position), not per-stream
// labels, so the "highest confidence wins" logic doesn't apply.
// Always run if detected; surface result as a separate field.
let disc_metadata = if bdmt::detect(udf) {
bdmt::parse(reader, udf)
} else {
None
};
let chapter_summary = collect_chapter_summary(reader, udf);
LabelAnalysis {
parser,
parsers_detected,
confidence,
jar_inventory: inventory,
labels,
disc_metadata,
gap_fill_added,
chapter_summary,
}
}
/// Scan `/BDMV/PLAYLIST/*.mpls`, parse each, return a row per playlist
/// with chapter count (mark_type ≤ 1) and total duration. Sorted by
/// playlist filename. Skipped entries (read error, parse error, no
/// marks) silently dropped — this is a diagnostic field, not a
/// correctness-critical one.
fn collect_chapter_summary(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<ChapterSummary> {
let Some(playlist_dir) = udf.find_dir("/BDMV/PLAYLIST") else {
return Vec::new();
};
let mut names: Vec<String> = playlist_dir
.entries
.iter()
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".mpls"))
.map(|e| e.name.clone())
.collect();
names.sort();
let mut out: Vec<ChapterSummary> = Vec::new();
for name in names {
let path = format!("/BDMV/PLAYLIST/{}", name);
let Ok(data) = udf.read_file(reader, &path) else {
continue;
};
let Ok(playlist) = crate::mpls::parse(&data) else {
continue;
};
let chapter_count = playlist.marks.iter().filter(|m| m.mark_type <= 1).count();
if chapter_count == 0 {
continue;
}
// Duration: sum of (out_time - in_time) across play items,
// each in 45kHz PTS ticks → seconds. Approximates the disc
// module's per-title duration; we don't claim sample accuracy
// here, just enough to identify "the long one" (main movie).
let duration_ticks: u64 = playlist
.play_items
.iter()
.map(|pi| pi.out_time.saturating_sub(pi.in_time) as u64)
.sum();
let duration_secs = duration_ticks as f64 / 45000.0;
out.push(ChapterSummary {
playlist: name,
chapter_count,
duration_secs,
});
}
out
}
/// Result of [`analyze`].
#[doc(hidden)]
#[derive(Debug, Clone)]
@@ -461,6 +768,30 @@ pub struct LabelAnalysis {
/// Raw labels emitted by the selected parser (empty if `parser`
/// is `None`).
pub labels: Vec<StreamLabel>,
/// Disc-level metadata from `/BDMV/META/DL/bdmt_*.xml` if present.
/// Localized title names, descriptions, box-set position. Orthogonal
/// to per-stream labels; populated independently from the parser
/// registry.
pub disc_metadata: Option<bdmt::DiscMetadata>,
/// Number of stream slots the MPLS gap-fill merge added on top of
/// the framework parser's output. 0 means the framework covered
/// every MPLS-known stream slot, or MPLS itself was the chosen
/// parser. Diagnostic for the labels-analyze tool.
pub gap_fill_added: usize,
/// Per-playlist chapter summary: `(playlist_filename, chapter_count, duration_secs)`.
/// Sourced from MPLS PlaylistMark entries with `mark_type ≤ 1`
/// (chapter entries). Ordered by playlist filename. Empty if no
/// MPLS files have parseable marks, or the disc isn't Blu-ray.
pub chapter_summary: Vec<ChapterSummary>,
}
/// One row of the per-playlist chapter summary in `LabelAnalysis`.
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct ChapterSummary {
pub playlist: String,
pub chapter_count: usize,
pub duration_secs: f64,
}
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
@@ -510,7 +841,7 @@ pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
/// Read a file from any BDMV/JAR subdirectory by filename.
pub(crate) fn read_jar_file(
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
udf: &UdfFs,
filename: &str,
) -> Option<Vec<u8>> {
@@ -544,12 +875,14 @@ mod registry_tests {
"pixelogic",
"ctrm",
"dbp",
"deluxe"
"deluxe",
"mpls_universal",
],
"PARSERS array order changed — confirm dbp + deluxe stay last \
(loose detect, real check in parse), and stricter parsers \
(paramount/criterion/pixelogic/ctrm — all file-presence \
gated detect) stay first."
"PARSERS array order changed — confirm dbp + deluxe stay just \
before mpls_universal (loose detect, real check in parse), \
stricter parsers (paramount/criterion/pixelogic/ctrm — all \
file-presence gated detect) stay first, and mpls_universal \
stays LAST as the universal Low-confidence fallback."
);
}
@@ -571,10 +904,175 @@ mod registry_tests {
}
}
// ── gap_fill_from_mpls tests ───────────────────────────────────────────────
#[cfg(test)]
mod gap_fill_tests {
use super::*;
fn label(t: StreamLabelType, n: u16, lang: &str, codec: &str) -> StreamLabel {
StreamLabel {
stream_number: n,
stream_type: t,
language: lang.into(),
name: String::new(),
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
codec_hint: codec.into(),
variant: String::new(),
}
}
#[test]
fn empty_framework_takes_all_mpls() {
let mut framework: Vec<StreamLabel> = Vec::new();
let mpls = vec![
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
label(StreamLabelType::Subtitle, 1, "eng", "PG"),
];
fill_gaps_from_mpls(&mut framework, &mpls);
assert_eq!(framework.len(), 3);
}
#[test]
fn framework_covers_all_mpls_no_op() {
let mut framework = vec![
label(StreamLabelType::Audio, 1, "eng", "Atmos"),
label(StreamLabelType::Audio, 2, "fra", "Atmos"),
];
let mpls = vec![
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
label(StreamLabelType::Audio, 2, "fra", "TrueHD"),
];
fill_gaps_from_mpls(&mut framework, &mpls);
assert_eq!(framework.len(), 2, "no gaps to fill");
// Framework entries kept verbatim — richer codec_hint survives.
assert_eq!(framework[0].codec_hint, "Atmos");
assert_eq!(framework[1].codec_hint, "Atmos");
}
#[test]
fn partial_yield_fills_gaps_keeps_framework() {
// Oppenheimer-style: framework matched but only labeled 2 of 6 audios.
let mut framework = vec![
label(StreamLabelType::Audio, 1, "eng", "Atmos"),
label(StreamLabelType::Audio, 4, "eng", "Commentary"),
label(StreamLabelType::Subtitle, 1, "eng", "PG SDH"),
];
let mpls = vec![
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
label(StreamLabelType::Audio, 3, "spa", "AC-3"),
label(StreamLabelType::Audio, 4, "eng", "AC-3"),
label(StreamLabelType::Audio, 5, "deu", "AC-3"),
label(StreamLabelType::Audio, 6, "ita", "AC-3"),
label(StreamLabelType::Subtitle, 1, "eng", "PG"),
label(StreamLabelType::Subtitle, 2, "fra", "PG"),
];
fill_gaps_from_mpls(&mut framework, &mpls);
assert_eq!(
framework.len(),
8,
"3 framework + 4 audio fills (2,3,5,6) + 1 subtitle fill (2) = 8"
);
// sort by (type, number) means audios first
let audios: Vec<_> = framework
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(audios.len(), 6, "all 6 audio slots covered");
// Slot 1 + 4 retain framework codec_hint
assert_eq!(audios[0].codec_hint, "Atmos");
assert_eq!(audios[3].codec_hint, "Commentary");
// Slots 2, 3, 5, 6 are MPLS-derived
assert_eq!(audios[1].codec_hint, "AC-3");
assert_eq!(audios[2].codec_hint, "AC-3");
}
#[test]
fn orphan_append_skips_matching_type_lang_codec_tuples() {
// If a "would-be orphan" actually shares (type, lang, codec)
// with a label the framework or MPLS already produced, drop
// it — the user-facing rendering would be a confusing
// duplicate. Stream_number is computed from the EXISTING
// labels' max(stream_number) per type so orphans (when they
// do fire) sort cleanly at the tail.
let labels = vec![
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
];
// Simulate the orphan dedup: build the existing-tuple set
// the way the production function does, then check exclusion.
use std::collections::HashSet;
let existing: HashSet<(StreamLabelType, String, String)> = labels
.iter()
.map(|l| (l.stream_type, l.language.clone(), l.codec_hint.clone()))
.collect();
let candidate = (
StreamLabelType::Audio,
"eng".to_string(),
"TrueHD".to_string(),
);
assert!(
existing.contains(&candidate),
"matching tuple must be detected as duplicate"
);
}
#[test]
fn orphan_append_genuine_orphan_assigned_next_stream_number() {
// Hypothetical scenario: framework emitted audio 1+2, MPLS
// gap-filled 3-5, CLPI has an orphan audio in (lang=jpn,
// codec=DTS) that doesn't collide. Expected: append as audio
// stream_number=6 (max existing + 1).
let mut labels = vec![
label(StreamLabelType::Audio, 1, "eng", "TrueHD 5.1"),
label(StreamLabelType::Audio, 2, "fra", "AC-3 5.1"),
label(StreamLabelType::Audio, 5, "eng", "AC-3 2.0"),
];
let max_audio: u16 = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.map(|l| l.stream_number)
.max()
.unwrap_or(0);
assert_eq!(max_audio, 5);
labels.push(label(StreamLabelType::Audio, max_audio + 1, "jpn", "DTS"));
labels.sort_by_key(|l| (type_tag(l.stream_type), l.stream_number));
let last_audio = labels
.iter()
.rev()
.find(|l| l.stream_type == StreamLabelType::Audio)
.unwrap();
assert_eq!(last_audio.stream_number, 6);
assert_eq!(last_audio.language, "jpn");
}
#[test]
fn sort_groups_audio_before_subtitle() {
let mut framework: Vec<StreamLabel> = Vec::new();
let mpls = vec![
label(StreamLabelType::Subtitle, 1, "eng", "PG"),
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
label(StreamLabelType::Subtitle, 2, "fra", "PG"),
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
];
fill_gaps_from_mpls(&mut framework, &mpls);
assert_eq!(framework.len(), 4);
assert_eq!(framework[0].stream_type, StreamLabelType::Audio);
assert_eq!(framework[0].stream_number, 1);
assert_eq!(framework[1].stream_type, StreamLabelType::Audio);
assert_eq!(framework[1].stream_number, 2);
assert_eq!(framework[2].stream_type, StreamLabelType::Subtitle);
assert_eq!(framework[3].stream_type, StreamLabelType::Subtitle);
}
}
// ── apply() integration tests ──────────────────────────────────────────────
//
// End-to-end coverage for the apply_labels + fill_defaults pipeline
// without needing a SectorReader / UdfFs. Synthetic DiscTitle +
// without needing a SectorSource / UdfFs. Synthetic DiscTitle +
// StreamLabel inputs, assert on the resulting Stream field values.
#[cfg(test)]
+597
View File
@@ -0,0 +1,597 @@
//! Universal MPLS-based stream labels.
//!
//! Unlike the framework-specific parsers in this directory (dbp,
//! pixelogic, ctrm, criterion, ...), this module is the *floor*:
//! every Blu-ray ships with MPLS playlists under `/BDMV/PLAYLIST/`,
//! and every MPLS file has an STN table with per-stream ISO 639-2
//! language codes plus coding-type / channel-layout / sample-rate
//! bytes from the BD spec.
//!
//! The framework parsers extract richer editorial labels ("English
//! Dolby Atmos", "Director's Commentary") when the disc was authored
//! with a recognized tool. When none of them match (e.g. a "no BD-J"
//! disc, or an authoring framework we haven't catalogued), MPLS still
//! gives us language + codec on every stream — enough to render
//! something more useful than the bare PID.
//!
//! Output confidence is Low: MPLS carries language + codec but
//! never purpose/qualifier info (no way to tell "Commentary" from
//! "Normal" from the STN table alone). Higher-confidence framework
//! parsers, when present, always win on the registry's max-by-confidence
//! tiebreaker — MPLS is only chosen when nothing else matched.
use super::{
LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType,
vocab::{self, LangInfo},
};
use crate::sector::SectorSource;
use crate::udf::UdfFs;
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
/// `.mpls` file. Cheap directory walk only — no sector reads.
pub fn detect(udf: &UdfFs) -> bool {
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
return false;
};
dir.entries
.iter()
.any(|e| !e.is_dir && has_mpls_extension(&e.name))
}
/// Walk every `*.mpls` in `/BDMV/PLAYLIST/`, parse it, and convert
/// each StreamEntry to a [`StreamLabel`]. Streams shared across
/// playlists (same PID) are deduped.
///
/// Returns `None` if no labels could be produced (e.g. no .mpls files
/// parsed successfully, or every parsed stream was a type we skip
/// like IG / DV EL).
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
let playlist_dir = udf.find_dir("/BDMV/PLAYLIST")?;
// Collect mpls filenames first so we don't hold a borrow on udf
// while we call udf.read_file (which takes &self).
let mpls_names: Vec<String> = playlist_dir
.entries
.iter()
.filter(|e| !e.is_dir && has_mpls_extension(&e.name))
.map(|e| e.name.clone())
.collect();
if mpls_names.is_empty() {
return None;
}
let mut labels: Vec<StreamLabel> = Vec::new();
// (stream_type_tag, language, codec_hint, pid) — PID is the
// canonical "same physical stream" key; type+lang+codec round
// out the rare case where two distinct logical streams happen
// to share a PID across playlists with different metadata.
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
// Global 1-based counters keyed by StreamLabelType. Incremented
// only when an entry survives dedup, so stream_numbers are dense
// (1, 2, 3, ...) per type across the whole disc — not reset per
// playlist. A disc with 2 MPLS files that each list the same
// 8 audio streams ends up with audio_1..audio_8, not audio_1..
// audio_16 or audio_1..audio_8 with audio_1 duplicated.
let mut audio_idx: u16 = 0;
let mut sub_idx: u16 = 0;
for name in &mpls_names {
let path = format!("/BDMV/PLAYLIST/{}", name);
let Ok(data) = udf.read_file(reader, &path) else {
continue;
};
let Ok(playlist) = crate::mpls::parse(&data) else {
continue;
};
for entry in &playlist.streams {
let label_type = match entry.stream_type {
2 | 5 => StreamLabelType::Audio, // primary + secondary audio
3 => StreamLabelType::Subtitle, // PG subtitle
// 1 = primary video, 6 = secondary video, 7 = DV EL
// → no StreamLabelType variant for video, skip.
// 4 = IG (interactive graphics) — not a user-facing
// stream, skip.
_ => continue,
};
let language = normalize_language(&entry.language);
let name = language_display_name(&language);
let codec_hint = build_codec_hint(label_type, entry);
let type_tag = type_tag(label_type);
let key = (type_tag, language.clone(), codec_hint.clone(), entry.pid);
if seen.contains(&key) {
continue;
}
seen.push(key);
let stream_number = match label_type {
StreamLabelType::Audio => {
audio_idx += 1;
audio_idx
}
StreamLabelType::Subtitle => {
sub_idx += 1;
sub_idx
}
};
labels.push(StreamLabel {
stream_number,
stream_type: label_type,
language,
name,
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
codec_hint,
variant: String::new(),
});
}
}
if labels.is_empty() {
return None;
}
// MPLS gives language + codec but never editorial info (no
// commentary/SDH/director's cut). Low confidence means framework
// parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always
// win when they match. MPLS only gets chosen as the parser when
// nothing else fired — exactly the universal-fallback role we want.
Some(ParseResult::low(labels))
}
fn has_mpls_extension(name: &str) -> bool {
// Case-insensitive ".mpls" suffix. Some discs use uppercase,
// some lowercase; UDF filenames preserve case but we don't.
let n = name.len();
if n < 5 {
return false;
}
name[n - 5..].eq_ignore_ascii_case(".mpls")
}
/// Lowercase + trim the raw 3-char ISO 639-2 code. If the lowered
/// string maps via [`vocab::lang`] (it won't for plain "eng" — that
/// matcher is for English-name fragments, not codes) use its
/// canonical code; otherwise return the trimmed lowercase string.
fn normalize_language(raw: &str) -> String {
let trimmed = raw.trim().to_ascii_lowercase();
if trimmed.is_empty() {
return String::new();
}
// vocab::lang() matches free-form English names, not ISO 639-2
// codes — so for the typical MPLS payload ("eng", "fra", ...)
// it returns None and we keep the trimmed code.
if let Some(LangInfo { code, .. }) = vocab::lang(&trimmed) {
return code.to_string();
}
trimmed
}
/// Human-readable English name for an ISO 639-2 code, or empty if
/// the code is unknown. Kept inline rather than in vocab because
/// vocab is the *reverse* mapping (name → code).
pub(crate) fn language_display_name(iso: &str) -> String {
match iso {
"eng" => "English",
"fra" | "fre" => "French",
"spa" => "Spanish",
"deu" | "ger" => "German",
"ita" => "Italian",
"jpn" => "Japanese",
"zho" | "chi" => "Chinese",
"kor" => "Korean",
"por" => "Portuguese",
"pol" => "Polish",
"ces" | "cze" => "Czech",
"hun" => "Hungarian",
"nld" | "dut" => "Dutch",
"ara" => "Arabic",
"hin" => "Hindi",
"tur" => "Turkish",
"tha" => "Thai",
"swe" => "Swedish",
"nor" => "Norwegian",
"dan" => "Danish",
"fin" => "Finnish",
"heb" => "Hebrew",
"rus" => "Russian",
"ell" | "gre" => "Greek",
"vie" => "Vietnamese",
"ind" => "Indonesian",
"msa" | "may" => "Malay",
"ukr" => "Ukrainian",
"ron" | "rum" => "Romanian",
"bul" => "Bulgarian",
"hrv" => "Croatian",
"srp" => "Serbian",
"slk" | "slo" => "Slovak",
"slv" => "Slovenian",
"est" => "Estonian",
"lav" => "Latvian",
"lit" => "Lithuanian",
"isl" | "ice" => "Icelandic",
"eus" | "baq" => "Basque",
"cat" => "Catalan",
"glg" => "Galician",
_ => "",
}
.to_string()
}
/// Map BD coding_type byte → codec name. Returns empty for unknown
/// bytes (the table covers everything the spec defines, but unknown
/// values are still possible on malformed discs).
pub(crate) fn codec_name(coding_type: u8) -> &'static str {
match coding_type {
0x02 => "MPEG-2",
0x1B => "H.264",
0x24 => "HEVC",
0x80 => "LPCM",
0x81 => "AC-3",
0x82 => "DTS",
0x83 => "TrueHD",
0x84 => "AC-3+",
0x85 => "DTS-HD",
0x86 => "DTS-HD MA",
0x90 => "PG",
0x91 => "IG",
0xA1 => "AC-3+ Secondary",
0xA2 => "DTS-HD Secondary",
_ => "",
}
}
/// Build the final `codec_hint`. For audio streams, optionally
/// append " <channels>" and/or " <rate>" suffixes. Sample rate is
/// only spelled out for non-48k (the universal default).
fn build_codec_hint(label_type: StreamLabelType, entry: &crate::mpls::StreamEntry) -> String {
let base = codec_name(entry.coding_type);
if base.is_empty() {
return String::new();
}
if label_type != StreamLabelType::Audio {
return base.to_string();
}
let mut out = base.to_string();
let channels = match entry.audio_format {
1 => Some("mono"),
3 => Some("2.0"),
6 => Some("5.1"),
12 => Some("7.1"),
_ => None,
};
if let Some(ch) = channels {
out.push(' ');
out.push_str(ch);
}
// 1 = 48 kHz (universal default, omit). Only call out higher rates.
let rate = match entry.audio_rate {
4 => Some("96kHz"),
5 => Some("192kHz"),
_ => None,
};
if let Some(r) = rate {
out.push(' ');
out.push_str(r);
}
out
}
/// Dedup-tag for the label type. `u8` instead of `StreamLabelType`
/// itself because the enum does not derive `Hash` / `Eq`-by-discriminant
/// in a way that we want to couple to (and `==` works fine for the
/// linear `Vec::contains` lookup we do).
fn type_tag(t: StreamLabelType) -> u8 {
match t {
StreamLabelType::Audio => 1,
StreamLabelType::Subtitle => 2,
}
}
// ── Tests ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::mpls::{Playlist, StreamEntry};
fn audio_entry(pid: u16, coding: u8, fmt: u8, rate: u8, lang: &str) -> StreamEntry {
StreamEntry {
stream_type: 2,
pid,
coding_type: coding,
video_format: 0,
video_rate: 0,
audio_format: fmt,
audio_rate: rate,
language: lang.to_string(),
dynamic_range: 0,
color_space: 0,
secondary: false,
}
}
fn pg_entry(pid: u16, lang: &str) -> StreamEntry {
StreamEntry {
stream_type: 3,
pid,
coding_type: 0x90,
video_format: 0,
video_rate: 0,
audio_format: 0,
audio_rate: 0,
language: lang.to_string(),
dynamic_range: 0,
color_space: 0,
secondary: false,
}
}
fn playlist_with(streams: Vec<StreamEntry>) -> Playlist {
Playlist {
version: "0200".to_string(),
play_items: Vec::new(),
streams,
marks: Vec::new(),
}
}
/// Drive the same conversion logic that `parse()` runs on real
/// disc data, but starting from already-parsed Playlists so we
/// don't have to synthesize valid MPLS bytes.
fn labels_from_playlists(playlists: &[Playlist]) -> Vec<StreamLabel> {
let mut labels: Vec<StreamLabel> = Vec::new();
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
for playlist in playlists {
let mut audio_idx: u16 = 0;
let mut sub_idx: u16 = 0;
for entry in &playlist.streams {
let label_type = match entry.stream_type {
2 | 5 => StreamLabelType::Audio,
3 => StreamLabelType::Subtitle,
_ => continue,
};
let stream_number = match label_type {
StreamLabelType::Audio => {
audio_idx += 1;
audio_idx
}
StreamLabelType::Subtitle => {
sub_idx += 1;
sub_idx
}
};
let language = normalize_language(&entry.language);
let name = language_display_name(&language);
let codec_hint = build_codec_hint(label_type, entry);
let key = (
type_tag(label_type),
language.clone(),
codec_hint.clone(),
entry.pid,
);
if seen.contains(&key) {
continue;
}
seen.push(key);
labels.push(StreamLabel {
stream_number,
stream_type: label_type,
language,
name,
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
codec_hint,
variant: String::new(),
});
}
}
labels
}
#[test]
fn mpls_audio_streams_become_labels() {
// Two audio streams: English TrueHD 7.1 48k, French AC-3 5.1 48k.
let pl = playlist_with(vec![
audio_entry(0x1100, 0x83, 12, 1, "eng"),
audio_entry(0x1101, 0x81, 6, 1, "fra"),
]);
let labels = labels_from_playlists(&[pl]);
assert_eq!(labels.len(), 2);
// English TrueHD 7.1
let a = &labels[0];
assert_eq!(a.stream_type, StreamLabelType::Audio);
assert_eq!(a.stream_number, 1);
assert_eq!(a.language, "eng");
assert_eq!(a.name, "English");
assert_eq!(a.codec_hint, "TrueHD 7.1");
assert_eq!(a.purpose, LabelPurpose::Normal);
assert_eq!(a.qualifier, LabelQualifier::None);
assert_eq!(a.variant, "");
// French AC-3 5.1
let b = &labels[1];
assert_eq!(b.stream_type, StreamLabelType::Audio);
assert_eq!(b.stream_number, 2);
assert_eq!(b.language, "fra");
assert_eq!(b.name, "French");
assert_eq!(b.codec_hint, "AC-3 5.1");
}
#[test]
fn mpls_pg_streams_become_subtitle_labels() {
let pl = playlist_with(vec![
pg_entry(0x1200, "eng"),
pg_entry(0x1201, "spa"),
pg_entry(0x1202, "fra"),
]);
let labels = labels_from_playlists(&[pl]);
assert_eq!(labels.len(), 3);
for label in &labels {
assert_eq!(label.stream_type, StreamLabelType::Subtitle);
assert_eq!(label.codec_hint, "PG");
}
assert_eq!(labels[0].stream_number, 1);
assert_eq!(labels[0].language, "eng");
assert_eq!(labels[0].name, "English");
assert_eq!(labels[1].stream_number, 2);
assert_eq!(labels[1].language, "spa");
assert_eq!(labels[1].name, "Spanish");
assert_eq!(labels[2].stream_number, 3);
assert_eq!(labels[2].language, "fra");
}
#[test]
fn dedup_streams_across_playlists() {
// Two playlists, same English TrueHD 7.1 PID 0x1100 in both.
// Expect one Audio label, not two.
let pl1 = playlist_with(vec![
audio_entry(0x1100, 0x83, 12, 1, "eng"),
audio_entry(0x1101, 0x81, 6, 1, "fra"),
]);
let pl2 = playlist_with(vec![
audio_entry(0x1100, 0x83, 12, 1, "eng"), // duplicate
audio_entry(0x1102, 0x82, 6, 1, "deu"), // new
]);
let labels = labels_from_playlists(&[pl1, pl2]);
// Expected: eng@0x1100, fra@0x1101, deu@0x1102 — three uniques.
assert_eq!(labels.len(), 3);
// PID isn't stored on StreamLabel, so assert on the surviving
// language set instead.
let mut langs: Vec<String> = labels.iter().map(|l| l.language.clone()).collect();
langs.sort();
assert_eq!(langs, vec!["deu", "eng", "fra"]);
}
#[test]
fn coding_type_to_codec_hint_table() {
// Spot-check every entry in the spec table. Audio entries
// come back bare (no channels/rate set) so codec_hint is the
// codec name alone.
let cases: &[(u8, &str)] = &[
(0x02, "MPEG-2"),
(0x1B, "H.264"),
(0x24, "HEVC"),
(0x80, "LPCM"),
(0x81, "AC-3"),
(0x82, "DTS"),
(0x83, "TrueHD"),
(0x84, "AC-3+"),
(0x85, "DTS-HD"),
(0x86, "DTS-HD MA"),
(0x90, "PG"),
(0x91, "IG"),
(0xA1, "AC-3+ Secondary"),
(0xA2, "DTS-HD Secondary"),
];
for (ct, expected) in cases {
assert_eq!(
codec_name(*ct),
*expected,
"coding_type 0x{:02X} should map to {}",
ct,
expected
);
}
// Unknown bytes return empty.
assert_eq!(codec_name(0x00), "");
assert_eq!(codec_name(0xFF), "");
}
#[test]
fn audio_format_appends_channel_layout() {
let mono = audio_entry(1, 0x83, 1, 1, "eng");
let stereo = audio_entry(2, 0x83, 3, 1, "eng");
let surround_51 = audio_entry(3, 0x83, 6, 1, "eng");
let surround_71 = audio_entry(4, 0x83, 12, 1, "eng");
let unknown = audio_entry(5, 0x83, 0, 1, "eng");
assert_eq!(
build_codec_hint(StreamLabelType::Audio, &mono),
"TrueHD mono"
);
assert_eq!(
build_codec_hint(StreamLabelType::Audio, &stereo),
"TrueHD 2.0"
);
assert_eq!(
build_codec_hint(StreamLabelType::Audio, &surround_51),
"TrueHD 5.1"
);
assert_eq!(
build_codec_hint(StreamLabelType::Audio, &surround_71),
"TrueHD 7.1"
);
assert_eq!(build_codec_hint(StreamLabelType::Audio, &unknown), "TrueHD");
}
#[test]
fn audio_rate_only_shows_above_48k() {
// 48 kHz (rate=1) is the universal default → not surfaced.
let r48 = audio_entry(1, 0x83, 6, 1, "eng");
// 96 kHz (rate=4) → surfaced.
let r96 = audio_entry(2, 0x83, 6, 4, "eng");
// 192 kHz (rate=5) → surfaced.
let r192 = audio_entry(3, 0x83, 6, 5, "eng");
assert_eq!(build_codec_hint(StreamLabelType::Audio, &r48), "TrueHD 5.1");
assert_eq!(
build_codec_hint(StreamLabelType::Audio, &r96),
"TrueHD 5.1 96kHz"
);
assert_eq!(
build_codec_hint(StreamLabelType::Audio, &r192),
"TrueHD 5.1 192kHz"
);
}
#[test]
fn unknown_iso_code_passes_through_without_display_name() {
// Made-up code: keep the raw lowercase code as `language`,
// but `name` is empty because we don't know it.
let pl = playlist_with(vec![audio_entry(0x1100, 0x83, 6, 1, "xyz")]);
let labels = labels_from_playlists(&[pl]);
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].language, "xyz");
assert_eq!(labels[0].name, "");
}
#[test]
fn ig_and_dv_streams_are_skipped() {
// stream_type 4 = IG, 7 = DV EL — both must not surface.
let mut ig = pg_entry(0x1400, "eng");
ig.stream_type = 4;
let mut dv = audio_entry(0x1011, 0x24, 0, 0, "");
dv.stream_type = 7;
let pl = playlist_with(vec![ig, dv]);
let labels = labels_from_playlists(&[pl]);
assert!(labels.is_empty());
}
#[test]
fn secondary_audio_becomes_audio_label() {
// stream_type 5 = secondary audio. The conversion should
// still produce an Audio label (the registry's apply path
// can ignore secondary if it wants — this module just
// surfaces what's there).
let mut sec = audio_entry(0x1A00, 0x83, 3, 1, "eng");
sec.stream_type = 5;
sec.secondary = true;
let pl = playlist_with(vec![sec]);
let labels = labels_from_playlists(&[pl]);
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
assert_eq!(labels[0].codec_hint, "TrueHD 2.0");
}
}
+2 -2
View File
@@ -13,14 +13,14 @@
//! ```
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "playlists.xml")
}
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult> {
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
let data = super::read_jar_file(reader, udf, "playlists.xml")?;
let text = std::str::from_utf8(&data).ok()?;
+2 -2
View File
@@ -9,7 +9,7 @@ use super::{
Confidence, LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, text,
vocab,
};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -24,7 +24,7 @@ pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "bluray_project.bin")
}
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult> {
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
let data = super::read_jar_file(reader, udf, "bluray_project.bin")?;
// min_len=4 matches the prior local extract_strings impl. The token
// grammar is `{lang3}_{codec?}_{purpose?}_{region?}_` so the
+20 -24
View File
@@ -82,7 +82,7 @@ pub mod event;
pub mod halt;
pub(crate) mod identity;
pub(crate) mod ifo;
pub(crate) mod io;
pub mod io;
pub mod keydb;
pub mod labels;
pub(crate) mod mpls;
@@ -128,16 +128,19 @@ pub use halt::Halt;
// Generic bounded producer/consumer primitive used by sweep, patch, and
// mux to overlap reads with writes via a dedicated consumer thread.
// `Pipeline::spawn(depth, sink)` spawns the consumer; `pipe.send(item)`
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
// pushes one item with back-pressure; `pipe.finish()` joins the
// consumer and surfaces its `close()` output. Callers implement `Sink`
// to define per-item behaviour and end-of-stream finalisation.
//
// `DEFAULT_PIPELINE_DEPTH` (=4) is the depth sweep + mux use; patch
// uses `WRITE_THROUGH_DEPTH` (=1) so each read fully drains before the
// next can enqueue. Returning `Flow::Stop` from `apply` ends the
// consumer cleanly (still calls `close()`).
pub use io::pipeline::{DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_THROUGH_DEPTH};
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
// most should use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
// `apply` ends the consumer cleanly (still calls `close()`).
pub use io::pipeline::{
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
WRITE_THROUGH_DEPTH,
};
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
pub use event::{Event, EventKind};
@@ -162,8 +165,8 @@ pub use decrypt::{DecryptKeys, decrypt_sectors};
// structured representation; never compare against display strings.
// Note: `disc::Stream` here is the codec enum (audio / video / sub kind)
// — not the `pes::Stream` trait re-exported below as `PesStream`. Two
// different concepts, the same short name; both stay because both are
// load-bearing in their respective domains.
// different concepts, the same short name; the trait gets the `Pes`
// prefix at the crate root to keep both addressable.
pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
@@ -176,7 +179,7 @@ pub use disc::{
// All stream types implement `pes::Stream` — read PES frames from a source,
// write PES frames to a sink. Pick the right type at construction:
//
// - `DiscStream` — physical drive or ISO (any `SectorReader`). Always read.
// - `DiscStream` — physical drive or ISO (any `SectorSource`). Read-only.
// - `MkvStream` — Matroska container. Read on `open()`, write on `create()`.
// - `M2tsStream` — Blu-ray Transport Stream. Read on `open()`, write on `create()`.
// - `NetworkStream` — TCP. Read on `listen()`, write on `connect()`.
@@ -186,14 +189,11 @@ pub use disc::{
// Most consumers use the URL resolvers (`input()` / `output()`) which pick
// the right type from a scheme:// URL. Direct construction is for callers
// that need to wire custom readers (e.g. autorip's drive-session reuse).
// 0.18 trait split: `FrameSource` (read-only) and `FrameSink` (write-only)
// supersede the unified `pes::Stream`. The old `Stream` re-export below
// stays available for the deprecation window — re-exported as
// `PesStream` to disambiguate from `disc::Stream` (the codec-kind enum
// re-exported above), which would otherwise collide at the crate root.
#[allow(deprecated)]
// The trait is re-exported as `PesStream` here to disambiguate from
// `disc::Stream` (the codec-kind enum re-exported above), which would
// otherwise collide at the crate root.
pub use pes::PesFrame;
pub use pes::Stream as PesStream;
pub use pes::{FrameSink, FrameSource, PesFrame};
pub use mux::DiscStream;
pub use mux::M2tsStream;
@@ -206,18 +206,14 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
// ─── Lower-level surfaces ───────────────────────────────────────────────────
//
// `ScsiTransport` is the platform-abstraction trait Drive uses; expose for
// out-of-tree platform backends. `SectorSource` / `SectorSink` are the 0.18
// out-of-tree platform backends. `SectorSource` / `SectorSink` are the
// direction-typed read/write traits; `FileSectorSource` and `FileSectorSink`
// are the ISO-on-disk implementations. [`DecryptingSectorSource`] is the
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
// `SectorSource` to get plaintext sectors out. The legacy `SectorReader` /
// `FileSectorReader` names stay re-exported through the 0.18 migration
// window so existing call sites compile unchanged; a blanket impl makes
// every `SectorReader` automatically usable as a `SectorSource`.
// `SectorSource` to get plaintext sectors out.
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
pub use sector::{
DecryptingSectorSource, FileSectorReader, FileSectorSink, FileSectorSource, SectorReader,
SectorSink, SectorSource,
DecryptingSectorSource, FileSectorSink, FileSectorSource, SectorSink, SectorSource,
};
pub use speed::DriveSpeed;
pub use udf::{UdfFs, read_filesystem};
+40 -32
View File
@@ -1,6 +1,6 @@
//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
//!
//! One stream type for all disc sources. The source is a SectorReader —
//! One stream type for all disc sources. The source is a SectorSource —
//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care.
//!
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
@@ -9,7 +9,7 @@ use crate::disc::{Disc, DiscTitle, Extent};
use crate::drive::extract_scsi_context;
use crate::event::{BatchSizeReason, Event, EventKind};
use crate::halt::Halt;
use crate::sector::{DecryptingSectorSource, SectorReader, SectorSource};
use crate::sector::{DecryptingSectorSource, SectorSource};
use std::io;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -97,7 +97,7 @@ impl AdaptiveBatch {
/// Disc stream. Reads sectors from any source → PES frames.
///
/// Sources: physical drive, ISO file, or any SectorReader.
/// Sources: physical drive, ISO file, or any SectorSource.
/// Decrypt, demux, and codec parsing happen internally.
pub struct DiscStream {
/// Underlying sector source wrapped in the 0.18
@@ -105,7 +105,7 @@ pub struct DiscStream {
/// call yields plaintext, so `fill_extents` no longer needs an
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
/// (raw / unencrypted disc) makes the decorator a pass-through.
reader: DecryptingSectorSource<Box<dyn SectorReader>>,
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
title: DiscTitle,
disc: Option<Disc>,
/// Mirror of the keys handed in at construction. The decorator
@@ -160,11 +160,11 @@ pub struct DiscStream {
impl DiscStream {
/// Create a disc stream from any sector reader.
///
/// Works with physical drives and ISO files — both implement SectorReader.
/// Works with physical drives and ISO files — both implement SectorSource.
/// The caller opens the source, scans for titles/keys, and passes them in.
/// The stream handles demuxing, decryption, and codec parsing internally.
pub fn new(
reader: Box<dyn SectorReader>,
reader: Box<dyn SectorSource>,
title: DiscTitle,
decrypt_keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
@@ -173,6 +173,13 @@ impl DiscStream {
let extents = title.extents.clone();
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
// Debug log reader type at construction — critical for diagnosing mux reading from drive instead of ISO
tracing::debug!(
target: "mux",
"DiscStream constructed with reader type: {}",
std::any::type_name::<dyn SectorSource>()
);
let mut pids = Vec::new();
let mut parsers = Vec::new();
let mut pid_to_track = Vec::new();
@@ -315,10 +322,19 @@ impl DiscStream {
// 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.
let start_time = std::time::Instant::now();
loop {
if self.is_halted() {
return Err(crate::error::Error::Halted.into());
}
// Debug: log slow reads during mux — helps diagnose stalls
if cfg!(debug_assertions) && start_time.elapsed().as_secs() > 5 {
tracing::debug!(target: "mux", "fill_extents waiting at LBA {} ({}s elapsed, sectors={})", lba, start_time.elapsed().as_secs(), remaining);
}
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().
@@ -387,7 +403,6 @@ impl DiscStream {
}
}
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
impl crate::pes::Stream for DiscStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
if let Some(frame) = self.pending_frames.pop_front() {
@@ -568,31 +583,26 @@ impl crate::pes::Stream for DiscStream {
#[cfg(test)]
mod tests {
//! `DiscStream` is the only meaningful `FrameSource` impl in tree (every
//! other concrete `pes::Stream` impl in `mux/*` is a sink). The 0.18
//! round-1 blanket `impl<T: pes::Stream + Send> pes::FrameSource for T`
//! covers `DiscStream` for free as long as it is `Send`. These tests
//! lock that down: a static `Send` assertion plus a `Box<dyn FrameSource>`
//! round trip exercising every `FrameSource` method through the trait
//! object, so future Send-breaking edits to `DiscStream`'s interior
//! types fail at compile time and the trait-bridge dispatch is verified
//! at runtime.
#![allow(deprecated)] // exercising the 0.18 deprecation-window blanket bridge.
//! `DiscStream` is the only read-only `Stream` impl in tree (every
//! other concrete impl in `mux/*` is bidirectional or write-only).
//! These tests lock down a static `Send` assertion plus a
//! `Box<dyn Stream>` round trip exercising every method through the
//! trait object, so future Send-breaking edits to `DiscStream`'s
//! interior types fail at compile time.
use super::*;
use crate::disc::{ContentFormat, DiscTitle};
use crate::pes::FrameSource;
use crate::pes::Stream;
/// Static-assert `DiscStream: Send`. The blanket
/// `impl<T: pes::Stream + Send> pes::FrameSource for T` only fires for
/// `Send` types — if a future field on `DiscStream` is non-`Send` (e.g.
/// a `Box<dyn Read>` instead of `Box<dyn SectorReader>`), this fails
/// Static-assert `DiscStream: Send`. The `Stream` trait has `Send` as a
/// supertrait — if a future field on `DiscStream` is non-`Send` (e.g.
/// a `Box<dyn Read>` instead of `Box<dyn SectorSource>`), this fails
/// at compile time, before the runtime trait-object test below.
fn _assert_disc_stream_is_send() {
fn requires_send<T: Send>() {}
requires_send::<DiscStream>();
}
/// Trivial `SectorReader` that yields zeroed sectors. Empty title means
/// Trivial `SectorSource` that yields zeroed sectors. Empty title means
/// the demuxer produces no PES frames, so `read()` walks the extents to
/// EOF and returns `Ok(None)`. That's enough to exercise the trait-object
/// dispatch — the goal here is the bridge, not the demuxer.
@@ -600,7 +610,7 @@ mod tests {
capacity: u32,
}
impl crate::sector::SectorReader for ZeroReader {
impl crate::sector::SectorSource for ZeroReader {
fn read_sectors(
&mut self,
_lba: u32,
@@ -613,7 +623,7 @@ mod tests {
Ok(bytes)
}
fn capacity(&self) -> u32 {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
@@ -628,13 +638,11 @@ mod tests {
}
}
/// Smallest credible witness that `DiscStream` flows through the
/// `FrameSource` blanket impl: build a `Box<dyn FrameSource>`, drive
/// `read()` to EOF, exercise `info()` / `headers_ready()` /
/// `codec_private()` through the trait object. The trait-bridge
/// correctness is what's being verified — not demuxer behaviour.
/// Smallest credible witness that `DiscStream` flows through `dyn Stream`:
/// build a `Box<dyn Stream>`, drive `read()` to EOF, exercise `info()` /
/// `headers_ready()` / `codec_private()` through the trait object.
#[test]
fn frame_source_via_dyn_object() {
fn stream_via_dyn_object() {
let reader = ZeroReader { capacity: 8 };
let title = synthetic_title(8);
let stream = DiscStream::new(
@@ -645,7 +653,7 @@ mod tests {
ContentFormat::BdTs,
);
let mut src: Box<dyn FrameSource> = Box::new(stream);
let mut src: Box<dyn Stream> = Box::new(stream);
// Empty-title fixture has no streams configured, so headers are
// trivially ready and codec_private() yields nothing on track 0.
+6 -6
View File
@@ -1,10 +1,10 @@
//! ISO sector reader — file-backed SectorReader for Blu-ray ISO images.
//! ISO sector reader — file-backed SectorSource for Blu-ray ISO images.
//!
//! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048.
//! Used by DiscStream::open_iso() and Disc::scan_image().
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
@@ -32,12 +32,12 @@ impl IsoSectorReader {
Ok(Self { file, capacity })
}
pub fn capacity(&self) -> u32 {
pub fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
impl SectorReader for IsoSectorReader {
impl SectorSource for IsoSectorReader {
fn read_sectors(
&mut self,
lba: u32,
@@ -73,7 +73,7 @@ mod tests {
std::fs::write(&dir, &data).unwrap();
let mut reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap();
assert_eq!(reader.capacity(), 4);
assert_eq!(reader.capacity_sectors(), 4);
let mut buf = [0u8; 2048];
reader.read_sectors(0, 1, &mut buf, true).unwrap();
@@ -94,7 +94,7 @@ mod tests {
std::fs::write(&dir, &data).unwrap();
let reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap();
assert_eq!(reader.capacity(), 10);
assert_eq!(reader.capacity_sectors(), 10);
std::fs::remove_file(&dir).ok();
}
-25
View File
@@ -173,7 +173,6 @@ impl M2tsStream {
}
}
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
impl crate::pes::Stream for M2tsStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
if let Some(frame) = self.pending_frames.pop_front() {
@@ -284,27 +283,3 @@ impl crate::pes::Stream for M2tsStream {
true
}
}
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
/// 0.18 deprecation window. Caller may pick either at the trait-object
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
/// Use `M2tsStream::create(writer, title)` to construct the write half;
/// calling `FrameSink::write` on an `M2tsStream::open(reader)` instance
/// returns `StreamReadOnly`.
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
impl crate::pes::FrameSink for M2tsStream {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
<Self as crate::pes::Stream>::write(self, frame)
}
fn finish(self: Box<Self>) -> io::Result<()> {
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
// Re-borrow inside the box, call Stream::finish, drop the box.
let mut s: Self = *self;
<Self as crate::pes::Stream>::finish(&mut s)
}
fn info(&self) -> &crate::disc::DiscTitle {
<Self as crate::pes::Stream>::info(self)
}
}
-30
View File
@@ -92,7 +92,6 @@ impl MkvStream {
}
}
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
impl crate::pes::Stream for MkvStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
let rs = match self.mode {
@@ -189,35 +188,6 @@ impl crate::pes::Stream for MkvStream {
}
}
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
/// 0.18 deprecation window. Caller may pick either at the trait-object
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
/// Use `MkvStream::create(writer, title)` to construct the write half;
/// calling `FrameSink::write` on a `MkvStream::open(reader)` instance returns
/// `StreamReadOnly`. `finish` is where the Cues index is written, so it must
/// be called for the resulting MKV to be seekable.
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
impl crate::pes::FrameSink for MkvStream {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
<Self as crate::pes::Stream>::write(self, frame)
}
fn finish(self: Box<Self>) -> io::Result<()> {
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
// Re-borrow inside the box, call Stream::finish, drop the box.
// The inner `MkvMuxer` is owned via `Option`, and `Stream::finish`
// already takes it via `Option::take()` — moving `*self` out of the
// box hands it the same field by-value, so the muxer's own
// by-value `finish()` runs correctly.
let mut s: Self = *self;
<Self as crate::pes::Stream>::finish(&mut s)
}
fn info(&self) -> &crate::disc::DiscTitle {
<Self as crate::pes::Stream>::info(self)
}
}
// ── MKV header parsing (read side) ────────────────────────────
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
-26
View File
@@ -70,7 +70,6 @@ impl NetworkStream {
}
}
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
impl crate::pes::Stream for NetworkStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
match &mut self.mode {
@@ -109,30 +108,6 @@ impl crate::pes::Stream for NetworkStream {
}
}
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
/// 0.18 deprecation window. Caller may pick either at the trait-object
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
/// Use `NetworkStream::connect(addr).meta(title)` to construct the write
/// half; calling `FrameSink::write` on `NetworkStream::listen(addr)` returns
/// `StreamReadOnly`.
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
impl crate::pes::FrameSink for NetworkStream {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
<Self as crate::pes::Stream>::write(self, frame)
}
fn finish(self: Box<Self>) -> io::Result<()> {
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
// Re-borrow inside the box, call Stream::finish, drop the box.
let mut s: Self = *self;
<Self as crate::pes::Stream>::finish(&mut s)
}
fn info(&self) -> &DiscTitle {
<Self as crate::pes::Stream>::info(self)
}
}
// NetworkStream is PES-only — no IOStream/Read/Write byte interface.
#[cfg(test)]
@@ -182,7 +157,6 @@ mod tests {
#[test]
#[ignore] // Requires TCP; may be flaky in CI environments
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
fn network_pes_roundtrip() {
use crate::pes;
+4 -34
View File
@@ -16,7 +16,6 @@ impl NullStream {
}
}
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSink in follow-up commit.
impl crate::pes::Stream for NullStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
Ok(None)
@@ -32,43 +31,16 @@ impl crate::pes::Stream for NullStream {
}
}
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
/// 0.18 deprecation window. Caller may pick either at the trait-object
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
/// The deprecation-window callers eventually migrate; this impl exists so
/// new callers can target `FrameSink` without waiting for the rest of the
/// migration to complete.
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
impl crate::pes::FrameSink for NullStream {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
<Self as crate::pes::Stream>::write(self, frame)
}
fn finish(self: Box<Self>) -> io::Result<()> {
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
// Re-borrow inside the box, call Stream::finish, drop the box.
let mut s: Self = *self;
<Self as crate::pes::Stream>::finish(&mut s)
}
fn info(&self) -> &DiscTitle {
<Self as crate::pes::Stream>::info(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pes::FrameSink;
use crate::pes::Stream;
/// Smallest credible witness that the new FrameSink impl on a concrete
/// `mux/*` sink works through the trait object: build a boxed
/// `dyn FrameSink`, write a frame, finish it. The trait-bridge correctness
/// is what's being verified — not NullStream-specific behaviour.
/// Verify NullStream routes through the `Stream` trait object cleanly.
#[test]
fn frame_sink_via_dyn_object_writes_and_finishes() {
fn stream_via_dyn_object_writes_and_finishes() {
let title = DiscTitle::empty();
let mut sink: Box<dyn FrameSink> = Box::new(NullStream::new(&title));
let mut sink: Box<dyn Stream> = Box::new(NullStream::new(&title));
let frame = crate::pes::PesFrame {
track: 0,
@@ -77,9 +49,7 @@ mod tests {
data: vec![0x01, 0x02, 0x03],
};
sink.write(&frame).unwrap();
// info() routes through the trait object.
let _ = sink.info();
// finish() consumes the Box<Self> — must compile and run.
sink.finish().unwrap();
}
}
+11 -7
View File
@@ -167,7 +167,6 @@ pub struct InputOptions {
}
/// Open a PES input stream (produces PES frames).
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource in follow-up commit.
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
@@ -187,7 +186,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
None => crate::disc::ScanOptions::default(),
};
let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?;
let capacity = reader.capacity();
let capacity = reader.capacity_sectors();
let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts)
.map_err(|e| -> io::Error { e.into() })?;
if disc.titles.is_empty() {
@@ -204,7 +203,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
let title = disc.titles[idx].clone();
let keys = disc.decrypt_keys();
let format = disc.content_format;
let mut stream = DiscStream::new(Box::new(reader), title, keys, 64, format);
// ISO file: use large batch size (16 MB) — sequential read from fast storage, no bad sectors.
// Physical drives need small batches for adaptive error handling and retry logic.
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
let mut stream =
DiscStream::new(Box::new(reader), title, keys, ISO_MUX_BATCH_SECTORS, format);
if opts.raw {
stream.set_raw();
}
@@ -235,7 +238,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
/// Open a PES output stream (consumes PES frames).
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSink in follow-up commit.
pub fn output(
url: &str,
title: &crate::disc::DiscTitle,
@@ -248,11 +250,13 @@ pub fn output(
// writeback) so a UHD-scale MKV mux to slow / network-attached
// staging doesn't hit the dirty-page burst pathology that
// sweep already side-steps. BufWriter sits on top to coalesce
// mux's many small EBML element writes.
// mux's many small EBML element writes. Pre-reserve the
// target's worth of extents on Linux via fallocate(KEEP_SIZE)
// to reduce extent fragmentation during the mux.
let writer: Box<dyn super::WriteSeek + Send> =
Box::new(std::io::BufWriter::with_capacity(
IO_BUF_SIZE,
crate::io::WritebackFile::create(path)?,
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
));
Ok(Box::new(MkvStream::create(writer, title)?))
}
@@ -260,7 +264,7 @@ pub fn output(
validate_file_path(path, "m2ts")?;
let writer = std::io::BufWriter::with_capacity(
IO_BUF_SIZE,
crate::io::WritebackFile::create(path)?,
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
);
Ok(Box::new(M2tsStream::create(writer, title)?))
}
-24
View File
@@ -60,7 +60,6 @@ impl StdioStream {
}
}
#[allow(deprecated)] // 0.18 trait split: migrate to FrameSource/FrameSink in follow-up commit.
impl crate::pes::Stream for StdioStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
self.ensure_header_read()?;
@@ -105,26 +104,3 @@ impl crate::pes::Stream for StdioStream {
self.header_read || self.writer.is_some()
}
}
/// FrameSink sibling to the deprecated Stream impl; both coexist during the
/// 0.18 deprecation window. Caller may pick either at the trait-object
/// boundary — `Box<dyn Stream>` (deprecated) or `Box<dyn FrameSink>` (new).
/// Use `StdioStream::output(title)` to construct the write half; calling
/// `FrameSink::write` on a `StdioStream::input()` returns `StreamReadOnly`.
#[allow(deprecated)] // delegating to deprecated Stream during the 0.18 deprecation window so callers don't see the deprecation twice.
impl crate::pes::FrameSink for StdioStream {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
<Self as crate::pes::Stream>::write(self, frame)
}
fn finish(self: Box<Self>) -> io::Result<()> {
// Why: Stream::finish takes &mut self, FrameSink::finish takes Box<Self>.
// Re-borrow inside the box, call Stream::finish, drop the box.
let mut s: Self = *self;
<Self as crate::pes::Stream>::finish(&mut s)
}
fn info(&self) -> &DiscTitle {
<Self as crate::pes::Stream>::info(self)
}
}
+44 -233
View File
@@ -78,19 +78,20 @@ impl PesFrame {
}
}
/// Deprecated; use [`FrameSource`] for read-only sources or [`FrameSink`]
/// for write-only sinks. The runtime direction-error semantics
/// (`StreamReadOnly` / `StreamWriteOnly` from a wrong-direction call) are
/// removed in 0.18 — direction is type-checked.
#[deprecated(
since = "0.18.0",
note = "use FrameSource (read-only) or FrameSink (write-only) instead"
)]
pub trait Stream {
/// Read the next frame, or `Ok(None)` at end of stream.
/// A PES frame stream. One trait per format — same type opens for read
/// (`open()` / `listen()` / `input()`) or write (`create()` / `connect()` /
/// `output()`). Calling the wrong-direction method returns a typed
/// `StreamReadOnly` / `StreamWriteOnly` error.
///
/// `Send` is required so streams can move across the producer/consumer
/// threads in autorip's mux pipeline.
pub trait Stream: Send {
/// Read the next frame, or `Ok(None)` at end of stream. Returns
/// `StreamWriteOnly` on a stream opened for writing.
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
/// Write a frame to the sink.
/// Write a frame to the sink. Returns `StreamReadOnly` on a stream
/// opened for reading.
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
/// Finalize the stream: flush buffered frames, write any container
@@ -116,115 +117,10 @@ pub trait Stream {
}
}
/// Read-only source of PES frames.
///
/// Replaces the read half of the deprecated [`Stream`] trait. Implementors
/// produce frames via [`read`](FrameSource::read) and never accept writes —
/// passing a `FrameSource` where a sink is expected is a compile error,
/// not a runtime `E9001`.
///
/// `info()` returns the source's `DiscTitle` metadata (track list, codec
/// info, duration). It must be stable across the lifetime of the source.
///
/// `codec_private(track)` exposes per-track codec initialization data
/// (H.264 SPS/PPS, HEVC VPS/SPS/PPS, AC-3 fscod, etc.) that downstream
/// muxers may need before any frame is written. `headers_ready()` returns
/// false until enough input frames have been seen to populate every video
/// track's codec-private blob — callers buffer frames they read until
/// `headers_ready()` returns true.
pub trait FrameSource: Send {
/// Read the next frame, or `Ok(None)` at end of stream.
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
/// Source metadata. Stable across reads — implementors must return a
/// consistent reference for the lifetime of the source.
fn info(&self) -> &crate::disc::DiscTitle;
/// Codec initialization data for a track (SPS/PPS, AC-3 fscod, etc.).
/// `None` for tracks that don't need codec_private (raw passthrough).
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> {
None
}
/// True when `codec_private` is available for every video track —
/// callers buffer input frames until this flips, since some output
/// formats (MKV) can't write frames without codec init data.
fn headers_ready(&self) -> bool {
true
}
}
/// Write-only sink of PES frames.
///
/// Replaces the write half of the deprecated [`Stream`] trait. Implementors
/// accept frames via [`write`](FrameSink::write) and finalize via
/// [`finish`](FrameSink::finish) — passing a `FrameSink` where a source is
/// expected is a compile error, not a runtime `E9000`.
///
/// `finish` takes `Box<Self>` (rather than `&mut self` like `Stream::finish`)
/// so that finalization is a one-shot terminal operation: callers cannot use
/// the sink after `finish` returns. This is the standard idiom for terminal
/// methods on `dyn Trait` objects.
pub trait FrameSink: Send {
/// Write a frame to the sink.
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
/// Finalize the sink: flush buffered frames, write any container index
/// (e.g. MKV `Cues`), close the underlying file/socket. Consumes the
/// sink — callers cannot use it afterwards.
fn finish(self: Box<Self>) -> std::io::Result<()>;
/// Sink metadata. Stable across writes — implementors must return a
/// consistent reference for the lifetime of the sink.
fn info(&self) -> &crate::disc::DiscTitle;
}
// Bridge: any **`Send`** type implementing the deprecated `Stream` trait
// is also a `FrameSource`. This lets existing concrete `Stream` impls in
// `mux/*` satisfy `FrameSource` bounds without per-type migration during
// the 0.18 deprecation window.
//
// **Send caveat (read me before tightening `Stream` itself).** This
// blanket carries a `T: Send` bound rather than promoting `Send` to a
// supertrait of `Stream`, because not every concrete in-tree `Stream`
// impl is `Send`: `MkvStream` and `M2tsStream` carry `Box<dyn Read>`
// and `Box<dyn Write>` fields whose trait objects don't include `Send`.
// Adding `Stream: Send` would force a wider audit (every `Box<dyn Read>`
// becomes `Box<dyn Read + Send>`) than this commit is taking on, and
// the type-level migration target is `FrameSource` / `FrameSink`
// directly anyway. Consequence: coercing a non-Send `Box<dyn Stream>`
// (the return shape of `crate::mux::input` / `output`) to
// `Box<dyn FrameSource>` will fail with a `T: Send` trait-bound error.
// The fix on the consumer side is to construct a Send-compliant
// `FrameSource` / `FrameSink` directly rather than relying on this
// bridge for non-Send streams.
//
// Note: `FrameSink` cannot be blanket-impl'd from `Stream` because
// `Stream::finish` takes `&mut self` while `FrameSink::finish` takes
// `Box<Self>`; concrete types will be migrated in a follow-up commit.
#[allow(deprecated)]
impl<T: Stream + Send + ?Sized> FrameSource for T {
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
<Self as Stream>::read(self)
}
fn info(&self) -> &crate::disc::DiscTitle {
<Self as Stream>::info(self)
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
<Self as Stream>::codec_private(self, track)
}
fn headers_ready(&self) -> bool {
<Self as Stream>::headers_ready(self)
}
}
/// Wraps any output stream and counts bytes written.
///
/// Progress tracking is a CLI concern — streams don't know their size.
/// Wrap the output with CountingStream, then query bytes_written().
/// Wrap the output with `CountingStream`, then query `bytes_written()`.
///
/// ```text
/// let mut output = CountingStream::new(libfreemkv::output(dest, &title)?);
@@ -233,13 +129,11 @@ impl<T: Stream + Send + ?Sized> FrameSource for T {
/// let pct = output.bytes_written() as f64 / total as f64;
/// }
/// ```
#[allow(deprecated)]
pub struct CountingStream {
inner: Box<dyn Stream>,
written: u64,
}
#[allow(deprecated)]
impl CountingStream {
pub fn new(inner: Box<dyn Stream>) -> Self {
Self { inner, written: 0 }
@@ -251,7 +145,6 @@ impl CountingStream {
}
}
#[allow(deprecated)]
impl Stream for CountingStream {
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
self.inner.read()
@@ -293,88 +186,35 @@ mod tests {
}
}
/// Direct `FrameSource` impl (not via the deprecated `Stream` blanket).
struct MockSource {
frames: std::vec::IntoIter<PesFrame>,
/// Minimal in-memory `Stream` for trait-shape tests. `read` replays
/// pre-seeded frames; `write` collects them.
struct MockStream {
read_queue: std::vec::IntoIter<PesFrame>,
written: Vec<PesFrame>,
title: DiscTitle,
}
impl MockSource {
fn new(frames: Vec<PesFrame>) -> Self {
impl MockStream {
fn new(read_frames: Vec<PesFrame>) -> Self {
Self {
frames: frames.into_iter(),
read_queue: read_frames.into_iter(),
written: Vec::new(),
title: DiscTitle::empty(),
}
}
}
impl FrameSource for MockSource {
impl Stream for MockStream {
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
Ok(self.frames.next())
Ok(self.read_queue.next())
}
fn info(&self) -> &DiscTitle {
&self.title
}
}
/// Direct `FrameSink` impl (not via the deprecated `Stream` blanket).
struct MockSink {
collected: Vec<PesFrame>,
title: DiscTitle,
}
impl MockSink {
fn new() -> Self {
Self {
collected: Vec::new(),
title: DiscTitle::empty(),
}
}
}
impl FrameSink for MockSink {
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()> {
self.collected.push(frame.clone());
self.written.push(frame.clone());
Ok(())
}
fn finish(self: Box<Self>) -> std::io::Result<()> {
// Drop self; in real sinks this is where flush/fsync/close happens.
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
}
/// Variant of `MockSink` whose `finish` returns the collected frames so
/// the test can assert on them after the consuming `Box<Self>` call.
struct CollectingSink {
collected: Vec<PesFrame>,
title: DiscTitle,
}
impl CollectingSink {
fn new() -> Self {
Self {
collected: Vec::new(),
title: DiscTitle::empty(),
}
}
}
impl FrameSink for CollectingSink {
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()> {
self.collected.push(frame.clone());
Ok(())
}
fn finish(self: Box<Self>) -> std::io::Result<()> {
// Real CollectingSink consumers would expose `take()` before
// finish; this trait method just confirms the boxed signature
// compiles and runs.
fn finish(&mut self) -> std::io::Result<()> {
Ok(())
}
@@ -384,72 +224,43 @@ mod tests {
}
#[test]
fn frame_source_yields_frames_then_eof() {
fn stream_read_yields_frames_then_eof() {
let frames = vec![make_frame(0, 0), make_frame(1, 1_000), make_frame(0, 2_000)];
let mut src = MockSource::new(frames.clone());
let mut s = MockStream::new(frames.clone());
let f0 = src.read().unwrap().expect("first frame");
let f0 = s.read().unwrap().expect("first frame");
assert_eq!(f0.track, frames[0].track);
assert_eq!(f0.pts, frames[0].pts);
assert!(f0.keyframe);
let f1 = src.read().unwrap().expect("second frame");
assert_eq!(f1.track, frames[1].track);
let f1 = s.read().unwrap().expect("second frame");
assert_eq!(f1.pts, frames[1].pts);
let f2 = src.read().unwrap().expect("third frame");
assert_eq!(f2.track, frames[2].track);
let f2 = s.read().unwrap().expect("third frame");
assert_eq!(f2.pts, frames[2].pts);
assert!(src.read().unwrap().is_none());
assert!(src.read().unwrap().is_none()); // idempotent at EOF
assert!(s.read().unwrap().is_none());
assert!(s.read().unwrap().is_none()); // idempotent at EOF
}
#[test]
fn frame_sink_collects_then_finishes() {
let mut sink = MockSink::new();
fn stream_write_collects_then_finishes() {
let mut s = MockStream::new(Vec::new());
let frames = [make_frame(0, 0), make_frame(1, 100), make_frame(2, 200)];
for f in &frames {
sink.write(f).unwrap();
s.write(f).unwrap();
}
assert_eq!(sink.collected.len(), 3);
assert_eq!(sink.collected[0].pts, 0);
assert_eq!(sink.collected[1].pts, 100);
assert_eq!(sink.collected[2].pts, 200);
// Box-and-finish — the `self: Box<Self>` shape must compile and run.
Box::new(sink).finish().unwrap();
assert_eq!(s.written.len(), 3);
s.finish().unwrap();
}
#[test]
fn frame_sink_via_dyn_object() {
let frames = [make_frame(0, 0), make_frame(0, 33)];
let mut sink: Box<dyn FrameSink> = Box::new(CollectingSink::new());
for f in &frames {
sink.write(f).unwrap();
}
// info() routes through the trait object.
let _ = sink.info();
sink.finish().unwrap();
}
/// The deprecated blanket impl: any concrete `Stream` should also act as
/// a `FrameSource`. `NullStream` has the simplest constructor of every
/// concrete `Stream` impl in `mux/*`, so it's the smallest credible
/// witness that the bridge compiles and dispatches correctly.
#[test]
#[allow(deprecated)]
fn deprecated_stream_satisfies_frame_source() {
let title = DiscTitle::empty();
let mut null = crate::mux::NullStream::new(&title);
let src: &mut dyn FrameSource = &mut null;
// NullStream::read returns Ok(None) — it's a write-only sink.
assert!(src.read().unwrap().is_none());
// info() forwards through the blanket impl.
let _ = src.info();
assert!(src.headers_ready());
fn stream_via_dyn_object() {
let mut s: Box<dyn Stream> = Box::new(MockStream::new(vec![make_frame(0, 0)]));
let frame = s.read().unwrap().expect("first frame");
s.write(&frame).unwrap();
let _ = s.info();
s.finish().unwrap();
}
}
+73 -8
View File
@@ -13,7 +13,13 @@ use std::path::Path;
use crate::error::{Error, Result};
use super::{SectorReader, SectorSink};
use super::{SectorSink, SectorSource};
/// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the
/// read side. Mirrors the writeback chunk size so the read-side
/// page cache stays bounded the same way the write side does.
#[cfg(target_os = "linux")]
const READ_DROP_CHUNK_BYTES: u64 = 32 * 1024 * 1024;
/// SectorSource backed by a file (ISO image).
///
@@ -27,6 +33,14 @@ use super::{SectorReader, SectorSink};
pub struct FileSectorSource {
file: File,
capacity: u32,
/// Bytes read since the last `posix_fadvise(DONTNEED)` drop.
/// Only updated on Linux; on other targets it stays at 0.
#[cfg(target_os = "linux")]
bytes_read_since_drop: u64,
/// Byte offset at which the current drop window starts (the
/// next `posix_fadvise(DONTNEED)` call drops from here).
#[cfg(target_os = "linux")]
drop_window_start: u64,
}
impl FileSectorSource {
@@ -45,18 +59,37 @@ impl FileSectorSource {
.into());
}
let capacity = sectors as u32;
Ok(Self { file, capacity })
// Hint sequential access on Linux so the kernel's readahead
// window widens for the ISO sweep. Best-effort: return value
// is ignored. On macOS / Windows this is a no-op.
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
unsafe {
libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_SEQUENTIAL);
}
}
Ok(Self {
file,
capacity,
#[cfg(target_os = "linux")]
bytes_read_since_drop: 0,
#[cfg(target_os = "linux")]
drop_window_start: 0,
})
}
}
// Implement the legacy `SectorReader` trait. The blanket impl in
// Implement the legacy `SectorSource` trait. The blanket impl in
// `super` produces the `SectorSource` impl automatically — no need
// to write both, and writing both would conflict. This keeps the
// 0.17 method-resolution path intact (callers with `SectorReader`
// 0.17 method-resolution path intact (callers with `SectorSource`
// in scope can still write `fsr.read_sectors(..)` against a
// `FileSectorSource`).
impl SectorReader for FileSectorSource {
fn capacity(&self) -> u32 {
impl SectorSource for FileSectorSource {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
@@ -75,6 +108,38 @@ impl SectorReader for FileSectorSource {
self.file
.read_exact(&mut buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
// On Linux, periodically drop the just-read region from the
// page cache to keep cache pressure bounded during multi-GB
// sequential ISO reads. Mirrors the write-side pipeline.
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
self.bytes_read_since_drop += bytes as u64;
if self.bytes_read_since_drop >= READ_DROP_CHUNK_BYTES {
let drop_start = self.drop_window_start;
let drop_len = self.bytes_read_since_drop;
let t0 = std::time::Instant::now();
unsafe {
libc::posix_fadvise(
self.file.as_raw_fd(),
drop_start as i64,
drop_len as i64,
libc::POSIX_FADV_DONTNEED,
);
}
let elapsed_ms = t0.elapsed().as_millis();
let start_lba = drop_start / 2048;
let end_lba = (drop_start + drop_len) / 2048;
tracing::trace!(
target: "mux",
"FileSectorSource fadvise DONTNEED lba=[{start_lba}..{end_lba}) bytes={drop_len} elapsed_ms={elapsed_ms}"
);
self.drop_window_start = drop_start + drop_len;
self.bytes_read_since_drop = 0;
}
}
Ok(bytes)
}
}
@@ -147,8 +212,8 @@ impl SectorSink for FileSectorSink {
#[cfg(test)]
mod tests {
// Bring the 0.18 trait into scope (not super::*: the super
// module also re-exports the legacy `SectorReader`, and
// having both `SectorReader::read_sectors` and
// module also re-exports the legacy `SectorSource`, and
// having both `SectorSource::read_sectors` and
// `SectorSource::read_sectors` visible would force every
// call site to disambiguate). External consumers see the
// same surface this test exercises.
+60 -149
View File
@@ -1,29 +1,17 @@
//! Sector-level I/O traits.
//!
//! 0.18 splits the unidirectional read trait from a write trait at
//! the sector layer, so the type system catches "wrong direction"
//! mistakes at compile time instead of runtime. See
//! `(internal)/memory/0_18_redesign.md`.
//! The sector layer is direction-typed: [`SectorSource`] reads
//! 2048-byte sectors, [`SectorSink`] writes them. Concrete impls
//! never do both — physical drives are read-only, file-backed
//! ISO images are opened for read OR write at construction time.
//!
//! - [`SectorSource`] reads 2048-byte sectors. Implemented by
//! `Drive` (via the legacy [`SectorReader`] alias) and
//! [`FileSectorSource`] (ISO-backed).
//! - [`SectorSink`] writes 2048-byte sectors. Implemented by
//! [`FileSectorSink`] (ISO-backed) and, in later commits, by
//! sweep/patch consumer adapters.
//! - [`SectorSource`] is implemented by `Drive` (hardware) and
//! [`FileSectorSource`] / `IsoSectorReader` (file-backed).
//! - [`SectorSink`] is implemented by [`FileSectorSink`]
//! (ISO-backed) and sweep/patch consumer adapters.
//! - [`DecryptingSectorSource`] is a decorator that wraps any
//! `SectorSource` and applies the existing AACS / CSS in-place
//! decrypt to plaintext-out.
//!
//! [`SectorReader`] is the 0.17 read trait. It stays on through
//! the 0.18 migration window so existing call sites
//! (`Drive`, `IsoSectorReader`, `BufferedSectorReader`,
//! `DiscStream`, `verify`) compile unchanged. A blanket impl
//! forwards every `SectorReader` impl to `SectorSource`, so new
//! code should target `SectorSource` / `SectorSink` directly. The
//! formal `#[deprecated]` attribute lands once the internal
//! callers have migrated; see the comment on `SectorReader` for
//! why this commit holds it back.
//! `SectorSource` and applies AACS / CSS in-place decrypt to
//! yield plaintext sectors.
pub mod decrypting;
pub mod file;
@@ -32,13 +20,14 @@ use crate::error::Result;
/// Read 2048-byte sectors from a disc, image, or composed source.
///
/// Direction-typed: a `SectorSource` cannot be written to. Wrap the
/// inner source in [`DecryptingSectorSource`] to get plaintext
/// sectors out of an encrypted disc.
/// Wrap the inner source in [`DecryptingSectorSource`] to get
/// plaintext sectors out of an encrypted disc.
pub trait SectorSource: Send {
/// Total capacity in sectors, if known. Returns 0 when unknown
/// Total capacity in sectors, if known. Default `0` = unknown
/// (e.g. live drives that haven't completed `READ CAPACITY` yet).
fn capacity_sectors(&self) -> u32;
fn capacity_sectors(&self) -> u32 {
0
}
/// Read `count` sectors starting at `lba` into `buf`.
/// `buf` must be at least `count * 2048` bytes.
@@ -60,10 +49,52 @@ pub trait SectorSource: Send {
fn set_speed(&mut self, _kbs: u16) {}
}
// Forwarding impls so `Box<dyn SectorSource>` and `&mut dyn SectorSource`
// satisfy the `SectorSource` trait bound when wrapped by generic
// decorators like `DecryptingSectorSource<S: SectorSource>`.
impl SectorSource for Box<dyn SectorSource> {
fn capacity_sectors(&self) -> u32 {
(**self).capacity_sectors()
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
(**self).read_sectors(lba, count, buf, recovery)
}
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
}
impl SectorSource for &mut (dyn SectorSource + '_) {
fn capacity_sectors(&self) -> u32 {
(**self).capacity_sectors()
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
(**self).read_sectors(lba, count, buf, recovery)
}
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
}
/// Write 2048-byte sectors to a disc image or composed sink.
///
/// Direction-typed: a `SectorSink` cannot be read from. The
/// terminal [`finish`] takes `Box<Self>` so it can run on `dyn
/// The terminal [`finish`] takes `Box<Self>` so it can run on `dyn
/// SectorSink` and consume the sink (`fsync` + close).
///
/// [`finish`]: SectorSink::finish
@@ -78,125 +109,5 @@ pub trait SectorSink: Send {
fn finish(self: Box<Self>) -> Result<()>;
}
/// 0.17 read trait. Slated for removal once internal call sites
/// migrate to [`SectorSource`] in follow-up commits; until then
/// it remains the trait that `Drive`, `IsoSectorReader`,
/// `BufferedSectorReader`, and existing `&mut dyn SectorReader`
/// signatures use unchanged.
///
/// New code should implement [`SectorSource`] directly. The
/// blanket impl below makes any `SectorReader` automatically
/// usable wherever a `SectorSource` is expected, so a one-way
/// migration off `SectorReader` is possible per-callsite without
/// touching the impls.
//
// NOTE: not marked `#[deprecated]` in this commit — `cargo clippy
// -- -D warnings` (the CI gauntlet) treats deprecation as an
// error, and the existing `Drive` / `udf::BufferedSectorReader` /
// `mux::DiscStream` / `verify` call sites all go through this
// trait. The deprecation attribute lands together with the
// migration commits that move those call sites to
// `SectorSource`. The behavioural contract — "this trait is
// going away in 0.18" — is documented above and tracked in
// `(internal)/memory/0_18_redesign.md`.
pub trait SectorReader: Send {
/// Read `count` sectors starting at `lba` into `buf`.
/// See [`SectorSource::read_sectors`] for semantics.
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize>;
/// Total capacity in sectors, if known.
fn capacity(&self) -> u32 {
0
}
fn set_speed(&mut self, _kbs: u16) {}
}
// Blanket impl: anything implementing the legacy `SectorReader`
// trait automatically satisfies `SectorSource`. This is what keeps
// existing impls (`Drive`, `IsoSectorReader`, `BufferedSectorReader`,
// etc.) compiling without source changes during the migration. The
// reverse direction (impl SectorReader for SectorSource) is
// intentionally NOT provided — new code targets the new trait.
impl<T: SectorReader + ?Sized> SectorSource for T {
fn capacity_sectors(&self) -> u32 {
<T as SectorReader>::capacity(self)
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
<T as SectorReader>::read_sectors(self, lba, count, buf, recovery)
}
fn set_speed(&mut self, kbs: u16) {
<T as SectorReader>::set_speed(self, kbs)
}
}
// Forwarding impls so callers can wrap `&mut dyn SectorReader` /
// `Box<dyn SectorReader>` in [`DecryptingSectorSource`] without
// having to unbox or re-borrow inside the lib's hot paths. The
// generic `&mut T` / `Box<T>` blankets would conflict with the
// `SectorReader → SectorSource` blanket above (a downstream crate
// could `impl SectorReader for &mut U`); the specific
// `dyn SectorReader` instantiations are unambiguous because
// `SectorReader` is the very trait whose `dyn` we're targeting.
impl SectorSource for &mut (dyn SectorReader + '_) {
fn capacity_sectors(&self) -> u32 {
<dyn SectorReader as SectorReader>::capacity(*self)
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
<dyn SectorReader as SectorReader>::read_sectors(*self, lba, count, buf, recovery)
}
fn set_speed(&mut self, kbs: u16) {
<dyn SectorReader as SectorReader>::set_speed(*self, kbs)
}
}
impl SectorSource for Box<dyn SectorReader> {
fn capacity_sectors(&self) -> u32 {
<dyn SectorReader as SectorReader>::capacity(&**self)
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
<dyn SectorReader as SectorReader>::read_sectors(&mut **self, lba, count, buf, recovery)
}
fn set_speed(&mut self, kbs: u16) {
<dyn SectorReader as SectorReader>::set_speed(&mut **self, kbs)
}
}
pub use decrypting::DecryptingSectorSource;
pub use file::{FileSectorSink, FileSectorSource};
// Backwards-compat alias for the public API. `FileSectorReader` is
// the 0.17 name; new code uses `FileSectorSource`. Both point at
// the same type. The `#[deprecated]` attribute lands together with
// the migration commits that retire the alias from internal uses.
pub type FileSectorReader = FileSectorSource;
+17 -17
View File
@@ -19,7 +19,7 @@
//! BD-ROM Part 3 — Blu-ray filesystem profile
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::sector::SectorSource;
/// A UDF filesystem parsed from disc.
#[derive(Debug)]
@@ -84,7 +84,7 @@ impl UdfFs {
/// Reads sector by sector from disc — no buffering.
/// Get the absolute starting LBA of a file on disc.
/// Used by the rip pipeline to locate m2ts content sectors.
pub fn file_start_lba(&self, reader: &mut dyn SectorReader, path: &str) -> Result<u32> {
pub fn file_start_lba(&self, reader: &mut dyn SectorSource, path: &str) -> Result<u32> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root;
for part in &parts[..parts.len() - 1] {
@@ -115,7 +115,7 @@ impl UdfFs {
Ok(self.partition_start + data_lba)
}
pub fn read_file(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<u8>> {
pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root;
@@ -175,7 +175,7 @@ impl UdfFs {
///
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
/// MKB_RO.inf, ContentHash*, ContentRevocation*
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure: sector 0 through end of metadata partition
@@ -194,7 +194,7 @@ impl UdfFs {
/// All sector ranges that contain data (metadata + all files including STREAM).
/// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps.
pub fn all_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure sectors
@@ -212,7 +212,7 @@ impl UdfFs {
fn collect_all_file_ranges(
&self,
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
entry: &DirEntry,
ranges: &mut Vec<(u32, u32)>,
) -> Result<()> {
@@ -238,7 +238,7 @@ impl UdfFs {
fn collect_file_ranges(
&self,
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
entry: &DirEntry,
ranges: &mut Vec<(u32, u32)>,
) -> Result<()> {
@@ -276,7 +276,7 @@ impl UdfFs {
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
/// and return its first allocation extent: (data_lba, data_length).
/// The data_lba is partition-relative.
fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> {
fn read_icb_extent(&self, reader: &mut dyn SectorSource, meta_lba: u32) -> Result<(u32, u32)> {
let extents = self.read_icb_extents(reader, meta_lba)?;
extents.first().copied().ok_or(Error::DiscRead {
sector: 0,
@@ -290,7 +290,7 @@ impl UdfFs {
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents).
fn read_icb_extents(
&self,
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
meta_lba: u32,
) -> Result<Vec<(u32, u32)>> {
let mut icb = [0u8; 2048];
@@ -367,7 +367,7 @@ impl UdfFs {
/// Returns Vec of (absolute_lba, sector_count) covering the entire file.
pub fn file_extents(
&self,
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
path: &str,
) -> Result<Vec<(u32, u32)>> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
@@ -417,7 +417,7 @@ impl UdfFs {
/// 3. Metadata partition file → metadata content location
/// 4. FSD → root directory ICB
/// 5. Root directory → file tree
pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
// Step 1: Anchor Volume Descriptor Pointer at sector 256
// ECMA-167 §10.2 — always at sector 256
let mut avdp = [0u8; 2048];
@@ -593,7 +593,7 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
/// and points to its ICB.
#[allow(clippy::only_used_in_recursion)]
fn read_directory(
reader: &mut dyn SectorReader,
reader: &mut dyn SectorSource,
part_start: u32,
meta_start: u32,
meta_lba: u32,
@@ -758,7 +758,7 @@ fn read_directory(
}
/// Read file size (info_length) from an Extended File Entry ICB.
fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result<u64> {
fn read_file_size(reader: &mut dyn SectorSource, meta_start: u32, meta_lba: u32) -> Result<u64> {
let mut icb = [0u8; 2048];
read_sector(reader, meta_start + meta_lba, &mut icb)?;
@@ -874,7 +874,7 @@ fn parse_dstring(data: &[u8]) -> String {
/// Each SCSI command has ~500ms overhead on USB drives, so reading 32 sectors
/// at once (one command) is 32x faster than 32 individual reads.
pub(crate) struct BufferedSectorReader<'a> {
inner: &'a mut dyn SectorReader,
inner: &'a mut dyn SectorSource,
cache_start: u32,
cache: Vec<u8>,
cache_sectors: u32,
@@ -884,7 +884,7 @@ pub(crate) struct BufferedSectorReader<'a> {
}
impl<'a> BufferedSectorReader<'a> {
pub(crate) fn new(inner: &'a mut dyn SectorReader, batch: u16) -> Self {
pub(crate) fn new(inner: &'a mut dyn SectorSource, batch: u16) -> Self {
Self {
inner,
cache_start: u32::MAX,
@@ -952,7 +952,7 @@ impl BufferedSectorReader<'_> {
}
}
impl SectorReader for BufferedSectorReader<'_> {
impl SectorSource for BufferedSectorReader<'_> {
fn read_sectors(
&mut self,
lba: u32,
@@ -996,7 +996,7 @@ impl SectorReader for BufferedSectorReader<'_> {
}
}
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
fn read_sector(reader: &mut dyn SectorSource, lba: u32, buf: &mut [u8]) -> Result<()> {
reader.read_sectors(lba, 1, buf, true)?;
Ok(())
}
+2 -2
View File
@@ -2,7 +2,7 @@
use crate::disc::{Chapter, DiscTitle};
use crate::progress::Progress;
use crate::sector::SectorReader;
use crate::sector::SectorSource;
use std::time::Instant;
/// Health status of a single sector read.
@@ -82,7 +82,7 @@ impl VerifyResult {
/// 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,
reader: &mut dyn SectorSource,
title: &DiscTitle,
batch_sectors: u16,
on_progress: Option<&dyn Progress>,
+2 -2
View File
@@ -1,6 +1,6 @@
//! Disc scanning pipeline tests.
use libfreemkv::SectorReader;
use libfreemkv::SectorSource;
use libfreemkv::error::Result;
use libfreemkv::{Disc, DiscTitle, ScanOptions};
use std::collections::HashMap;
@@ -20,7 +20,7 @@ impl MockSectorReader {
}
}
impl SectorReader for MockSectorReader {
impl SectorSource for MockSectorReader {
fn read_sectors(
&mut self,
lba: u32,
+43 -33
View File
@@ -1,17 +1,12 @@
//! Integration tests for progress reporting, halt behavior, drop safety,
//! and the file-backed sector reader round trip.
// 0.18 trait split: this suite still drives the deprecated `pes::Stream`
// trait directly. It will be migrated to `FrameSource`/`FrameSink` in the
// follow-up that ports concrete impls.
#![allow(deprecated)]
use libfreemkv::disc::{CopyOptions, DiscRegion};
use libfreemkv::error::Result;
use libfreemkv::pes::Stream as PesStream;
use libfreemkv::{
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorReader,
SectorReader,
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorSource,
SectorSource,
};
use std::io::Write;
use std::sync::Arc;
@@ -37,7 +32,7 @@ impl ZeroSectorReader {
}
}
impl SectorReader for ZeroSectorReader {
impl SectorSource for ZeroSectorReader {
fn read_sectors(
&mut self,
_lba: u32,
@@ -51,7 +46,7 @@ impl SectorReader for ZeroSectorReader {
Ok(bytes)
}
fn capacity(&self) -> u32 {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
@@ -72,7 +67,7 @@ impl SlowZeroSectorReader {
}
}
impl SectorReader for SlowZeroSectorReader {
impl SectorSource for SlowZeroSectorReader {
fn read_sectors(
&mut self,
_lba: u32,
@@ -86,7 +81,7 @@ impl SectorReader for SlowZeroSectorReader {
Ok(bytes)
}
fn capacity(&self) -> u32 {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
@@ -106,6 +101,7 @@ fn synthetic_disc(capacity_sectors: u32) -> Disc {
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
content_format: ContentFormat::BdTs,
}
}
@@ -308,7 +304,7 @@ fn test_drop_impls_do_not_panic_or_block() {
panic!("DiscStream drop did not complete within 100ms");
}
// ── 5. FileSectorReader round trip ────────────────────────────────────────
// ── 5. FileSectorSource round trip ────────────────────────────────────────
#[test]
fn test_file_sector_reader_round_trip() {
@@ -325,9 +321,13 @@ fn test_file_sector_reader_round_trip() {
tmp.flush().expect("flush");
let path = tmp.path().to_path_buf();
let mut fsr = FileSectorReader::open(&path).expect("open FileSectorReader");
let mut fsr = FileSectorSource::open(&path).expect("open FileSectorSource");
assert_eq!(fsr.capacity(), N_SECTORS as u32, "capacity mismatch");
assert_eq!(
fsr.capacity_sectors(),
N_SECTORS as u32,
"capacity mismatch"
);
// Read each sector individually and compare.
let mut buf = vec![0u8; SECTOR_SIZE];
@@ -395,7 +395,7 @@ impl FailingSectorReader {
}
}
impl SectorReader for FailingSectorReader {
impl SectorSource for FailingSectorReader {
fn read_sectors(
&mut self,
_lba: u32,
@@ -422,7 +422,7 @@ impl SectorReader for FailingSectorReader {
})
}
fn capacity(&self) -> u32 {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
@@ -571,7 +571,7 @@ struct BlockSizeFailingReader {
capacity: u32,
}
impl SectorReader for BlockSizeFailingReader {
impl SectorSource for BlockSizeFailingReader {
fn read_sectors(
&mut self,
lba: u32,
@@ -597,7 +597,7 @@ impl SectorReader for BlockSizeFailingReader {
}
}
fn capacity(&self) -> u32 {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
@@ -655,12 +655,16 @@ fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
//
// The video-damage-time display needs bytes_unreadable_total (confirmed dead)
// separate from bytes_pending_total (might still recover). This test verifies
// that a Pass 2 with some confirmed failures produces correct field values.
// 2026-05-11 design call: Pass N never marks bytes as `Unreadable` mid-multipass —
// failed reads stay `NonTrimmed` so the next pass can retry them. The orchestrator
// (autorip) promotes still-NonTrimmed bytes to Unreadable after the FINAL retry
// pass completes. This test was rewritten from its pre-design-call shape (which
// asserted Pass 2 produced bytes_unreadable > 0) to verify the new invariant:
// pass-level retries keep failed bytes in `bytes_pending` so subsequent passes
// get more shots at them.
#[test]
fn test_pass_progress_separates_unreadable_from_pending() {
fn test_pass2_leaves_failed_reads_as_pending_not_unreadable() {
let capacity_sectors: u32 = 128;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
@@ -734,24 +738,30 @@ fn test_pass_progress_separates_unreadable_from_pending() {
pass2.bytes_good, 0,
"pass2: still no good sectors (reader always fails)"
);
assert!(
pass2.bytes_unreadable > 0,
"pass2: some sectors confirmed unreadable"
// 2026-05-11 design: pass-level retries do NOT promote failed bytes
// to Unreadable. Failed bytes stay NonTrimmed (pending) so a later
// pass can retry. End-of-recovery promotion is an orchestrator
// concern (autorip), not the patch loop's.
assert_eq!(
pass2.bytes_unreadable, 0,
"pass2: Disc::patch never marks Unreadable mid-multipass — orchestrator promotes after final pass"
);
assert!(
pass2.bytes_pending < pass1.bytes_pending,
"pass2: fewer pending sectors than pass1"
// bytes_pending stays at total_bytes because everything still
// failed and nothing got recovered or promoted out of pending.
assert_eq!(
pass2.bytes_pending, total_bytes,
"pass2: failed bytes remain NonTrimmed for the next pass to retry"
);
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
let observed_pending = last_pending.load(Ordering::Relaxed);
assert!(
observed_unreadable > 0,
"progress should report confirmed unreadable bytes"
assert_eq!(
observed_unreadable, 0,
"progress should report zero confirmed-unreadable mid-pass under the new design"
);
assert!(
observed_pending == 0 || observed_pending < total_bytes,
"pending should shrink as sectors are confirmed unreadable"
observed_pending > 0,
"progress should report pending bytes as the reader keeps failing"
);
// Video damage time: unreadable / total * duration
+12 -5
View File
@@ -15,7 +15,7 @@ use libfreemkv::disc::CopyOptions;
use libfreemkv::disc::DiscRegion;
use libfreemkv::disc::mapfile::{Mapfile, SectorStatus};
use libfreemkv::error::Result;
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorReader};
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorSource};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
@@ -46,7 +46,7 @@ impl PatternedSectorReader {
}
}
impl SectorReader for PatternedSectorReader {
impl SectorSource for PatternedSectorReader {
fn read_sectors(
&mut self,
lba: u32,
@@ -70,13 +70,19 @@ impl SectorReader for PatternedSectorReader {
});
}
}
for chunk in buf.chunks_mut(SECTOR_SIZE) {
chunk.fill((lba & 0xff) as u8);
// Fill each sector with ITS OWN LBA byte, not the starting LBA's
// byte. This matches real drive behavior: a multi-sector READ
// returns per-sector-correct data. Pre-0.18.13 only single-sector
// reads were exercised by patch tests, so the cheaper "fill the
// whole batch with one byte" worked; adaptive batching needs the
// per-sector pattern to verify correct positioning.
for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() {
chunk.fill(((lba + i as u32) & 0xff) as u8);
}
Ok(buf.len())
}
fn capacity(&self) -> u32 {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
@@ -94,6 +100,7 @@ fn synthetic_disc(capacity_sectors: u32) -> Disc {
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
content_format: ContentFormat::BdTs,
}
}
-5
View File
@@ -1,10 +1,5 @@
//! Integration tests for the PES stream pipeline.
// 0.18 trait split: this suite still drives the deprecated `pes::Stream`
// trait directly. It will be migrated to `FrameSource`/`FrameSink` in the
// follow-up that ports concrete impls.
#![allow(deprecated)]
use libfreemkv::mux::meta::M2tsMeta;
use libfreemkv::pes::Stream as PesStream;
use libfreemkv::*;
+4 -4
View File
@@ -1,7 +1,7 @@
//! UDF parser tests using a MockSectorReader.
use libfreemkv::error::Result;
use libfreemkv::{SectorReader, read_filesystem};
use libfreemkv::{SectorSource, read_filesystem};
use std::collections::HashMap;
const SECTOR_SIZE: usize = 2048;
@@ -38,7 +38,7 @@ impl MockSectorReader {
}
}
impl SectorReader for MockSectorReader {
impl SectorSource for MockSectorReader {
fn read_sectors(
&mut self,
lba: u32,
@@ -463,11 +463,11 @@ fn find_dir_case_insensitive() {
#[test]
fn sector_reader_is_object_safe() {
// Verify SectorReader can be used as a trait object
// Verify SectorSource can be used as a trait object
let mut reader = MockSectorReader::new();
reader.set_sector(0, vec![42u8; SECTOR_SIZE]);
let dyn_reader: &mut dyn SectorReader = &mut reader;
let dyn_reader: &mut dyn SectorSource = &mut reader;
let mut buf = vec![0u8; SECTOR_SIZE];
let n = dyn_reader.read_sectors(0, 1, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE);