From a42241eb15618595bd246aab6617fb307ec15001 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:46:32 -0700 Subject: [PATCH] Prepare 1.7.7 with concise comments and shared CI policy --- .github/workflows/comment-guard.yml | 2 +- CHANGELOG.md | 15 + Cargo.lock | 10 +- Cargo.toml | 2 +- docs/FVI_FORMAT.md => FVI_FORMAT.md | 0 README.md | 36 +- TROUBLESHOOTING.md | 4 +- docs/README.md | 35 - docs/aacs-content.md | 190 ----- docs/aacs-crypto.md | 89 --- docs/aacs-derive.md | 68 -- docs/aacs-resolve.md | 78 -- docs/aacs-trace.md | 44 -- docs/aacs.md | 216 ------ docs/ac3.md | 220 ------ docs/adts.md | 17 - docs/api-design.md | 237 ------ docs/architecture.md | 222 ------ docs/au-assembly.md | 217 ------ docs/bdmt.md | 42 -- docs/bdnav.md | 21 - docs/bluray.md | 239 ------ docs/bounded.md | 61 -- docs/byte-prefetcher.md | 111 --- docs/class-reader.md | 48 -- docs/clpi-audit.md | 18 - docs/clpi.md | 243 ------- docs/codec-mod.md | 79 -- docs/coding.md | 17 - docs/consts.md | 11 - docs/crc.md | 14 - docs/criterion.md | 87 --- docs/css-keyless.md | 245 ------- docs/css-mod.md | 311 -------- docs/ctrm.md | 32 - docs/dbp.md | 39 - docs/decrypt.md | 500 ------------- docs/decrypting-sector-source.md | 34 - docs/deluxe.md | 227 ------ docs/demux-sink.md | 77 -- docs/demux-thread.md | 65 -- docs/diag.md | 65 -- docs/dirimage-tests.md | 132 ---- docs/dirimage.md | 185 ----- docs/disc-mod.md | 492 ------------- docs/disc-profile.md | 9 - docs/disc-tests.md | 17 - docs/disc-to-rip.md | 135 ---- docs/drive-access.md | 254 ------- docs/drive-linux.md | 15 - docs/drive-mod.md | 142 ---- docs/dropgate.md | 69 -- docs/dts.md | 309 -------- docs/dvd-audio-probe.md | 149 ---- docs/dvd.md | 177 ----- docs/dvdnav-mod.md | 23 - docs/dvdsub-mod.md | 28 - docs/ebml.md | 62 -- docs/encode.md | 51 -- docs/encrypt.md | 151 ---- docs/error-codes.md | 181 ----- docs/extract.md | 150 ---- docs/ffmpeg-validation.md | 68 -- docs/file-sector-source-linux.md | 36 - docs/file-sector-source-macos.md | 29 - docs/file-sector-source.md | 77 -- docs/flac.md | 16 - docs/fmp4-mod.md | 36 - docs/fox.md | 70 -- docs/fs-type-linux.md | 13 - docs/fs-type.md | 12 - docs/fsync.md | 20 - docs/fvi-sink.md | 37 - docs/h264.md | 189 ----- docs/halt.md | 16 - docs/harness.md | 35 - docs/hddvd.md | 465 ------------ docs/hevc-mux.md | 99 --- docs/hevc.md | 283 -------- docs/hex.md | 26 - docs/identity.md | 75 -- docs/ifo.md | 168 ----- docs/image-writer.md | 35 - docs/index-bdmv.md | 10 - docs/index-select.md | 7 - docs/inf.md | 100 --- docs/io-mod.md | 34 - docs/jar.md | 25 - docs/key-provider.md | 68 -- docs/keysource.md | 280 ------- docs/labels-mod.md | 556 -------------- docs/labels-xml.md | 26 - docs/lfsr.md | 166 ----- docs/lib-overview.md | 53 -- docs/local-file-sink.md | 34 - docs/lpcm.md | 29 - docs/m2ts-mux.md | 119 --- docs/m2ts.md | 25 - docs/meta-sink.md | 35 - docs/mkb.md | 69 -- docs/mkv-mux.md | 592 --------------- docs/mkvstream.md | 256 ------- docs/mobj.md | 9 - docs/mp4-audio.md | 110 --- docs/mp4-mux.md | 267 ------- docs/mp4-read.md | 499 ------------- docs/mpeg2.md | 101 --- docs/mpegaudio.md | 17 - docs/mpls-universal.md | 95 --- docs/mpls.md | 223 ------ docs/mux-disc.md | 94 --- docs/mux-driver.md | 235 ------ docs/mux-select.md | 54 -- docs/mux-timeline.md | 634 ---------------- docs/nav.md | 79 -- docs/network-mod.md | 36 - docs/paramount.md | 219 ------ docs/pesbuf.md | 59 -- docs/pgs-forced-probe.md | 452 ------------ docs/pgs.md | 131 ---- docs/pipeline.md | 228 ------ docs/pipelined-stream.md | 38 - docs/pixelogic.md | 199 ----- docs/platform-macos.md | 12 - docs/png_filenames.md | 21 - docs/prefetched.md | 237 ------ docs/progress.md | 31 - docs/ps-mod.md | 219 ------ docs/reorder.md | 59 -- docs/resolve.md | 1049 --------------------------- docs/resync.md | 44 -- docs/rip-recovery.md | 98 --- docs/scsi-error-decoding.md | 42 -- docs/scsi-linux.md | 49 -- docs/scsi-macos.md | 83 --- docs/scsi-mod.md | 155 ---- docs/scsi-windows.md | 87 --- docs/sector.md | 47 -- docs/segment-key.md | 30 - docs/segment.md | 39 - docs/session.md | 106 --- docs/sink.md | 82 --- docs/socket.md | 77 -- docs/startcode.md | 12 - docs/tables.md | 76 -- docs/testlog.md | 63 -- docs/truehd.md | 98 --- docs/ts-demux.md | 55 -- docs/tsmux.md | 76 -- docs/udf.md | 440 ----------- docs/unlock-bridge.md | 32 - docs/variant.md | 369 ---------- docs/vc1.md | 75 -- docs/videomap-mod.md | 27 - docs/videomap.md | 10 - docs/vm.md | 5 - docs/vocab.md | 145 ---- docs/writeback-file-linux.md | 57 -- docs/writeback-file-macos.md | 74 -- docs/writeback-file-windows.md | 16 - docs/writeback-file.md | 103 --- docs/writeback-linux.md | 70 -- src/aacs/content.rs | 56 +- src/aacs/crypto.rs | 36 +- src/aacs/derive.rs | 31 +- src/aacs/index_select.rs | 2 - src/aacs/inf.rs | 27 +- src/aacs/mkb.rs | 25 +- src/aacs/mod.rs | 15 +- src/aacs/provider.rs | 17 +- src/aacs/resolve.rs | 26 +- src/aacs/segment.rs | 16 +- src/aacs/segment_key.rs | 1 - src/aacs/trace.rs | 7 +- src/aacs/types.rs | 6 +- src/aacs/variant.rs | 102 +-- src/bdnav/index.rs | 2 - src/bdnav/mobj.rs | 1 - src/bdnav/mod.rs | 7 +- src/bdnav/vm.rs | 2 - src/clpi.rs | 15 +- src/consts.rs | 2 - src/css/keyless.rs | 90 +-- src/css/lfsr.rs | 52 +- src/css/mod.rs | 111 ++- src/css/tables.rs | 36 +- src/decrypt.rs | 145 ++-- src/diag.rs | 35 +- src/dirimage/encode.rs | 22 +- src/dirimage/layout.rs | 83 +-- src/dirimage/mod.rs | 2 - src/dirimage/tests.rs | 50 +- src/disc/bluray.rs | 91 +-- src/disc/dvd.rs | 48 +- src/disc/dvd_audio_probe.rs | 84 +-- src/disc/encrypt.rs | 63 +- src/disc/extract.rs | 63 +- src/disc/hddvd.rs | 147 ++-- src/disc/mod.rs | 200 +++-- src/disc/pgs_forced_probe.rs | 221 +++--- src/disc/profile.rs | 2 - src/drive/linux.rs | 5 +- src/drive/mod.rs | 82 +-- src/drive/windows.rs | 4 +- src/dvdnav/mod.rs | 6 +- src/dvdnav/nav.rs | 30 +- src/error.rs | 114 ++- src/halt.rs | 4 - src/harness.rs | 3 +- src/hex.rs | 14 +- src/identity.rs | 27 +- src/ifo.rs | 78 +- src/io/bounded.rs | 12 +- src/io/byte_prefetcher.rs | 59 +- src/io/file_sector_source/linux.rs | 6 +- src/io/file_sector_source/macos.rs | 20 +- src/io/file_sector_source/mod.rs | 28 +- src/io/fsync/mod.rs | 7 +- src/io/image_writer.rs | 10 +- src/io/mod.rs | 2 - src/io/pipeline.rs | 101 +-- src/io/platform_macos.rs | 6 +- src/io/sink/local_file.rs | 1 - src/io/sink/mod.rs | 35 +- src/io/sink/socket.rs | 23 +- src/io/writeback/linux.rs | 7 +- src/io/writeback_file/linux.rs | 15 +- src/io/writeback_file/macos.rs | 22 +- src/io/writeback_file/mod.rs | 63 +- src/io/writeback_file/windows.rs | 2 - src/keysource.rs | 143 ++-- src/labels/bdj_feature.rs | 30 +- src/labels/bdmt.rs | 24 +- src/labels/class_reader.rs | 16 +- src/labels/clpi_audit.rs | 8 +- src/labels/criterion.rs | 20 +- src/labels/ctrm.rs | 6 - src/labels/dbp.rs | 15 +- src/labels/deluxe.rs | 44 +- src/labels/fox.rs | 21 +- src/labels/jar.rs | 15 +- src/labels/mod.rs | 182 ++--- src/labels/mpls_universal.rs | 32 +- src/labels/paramount.rs | 73 +- src/labels/pixelogic.rs | 65 +- src/labels/png_filenames.rs | 7 +- src/labels/vocab.rs | 42 +- src/labels/xml.rs | 11 +- src/lib.rs | 3 +- src/mpls.rs | 27 +- src/mux/au_assembly.rs | 70 +- src/mux/codec/ac3.rs | 61 +- src/mux/codec/adts.rs | 10 +- src/mux/codec/coding.rs | 2 - src/mux/codec/crc.rs | 5 +- src/mux/codec/dropgate.rs | 18 +- src/mux/codec/dts.rs | 154 ++-- src/mux/codec/dvdsub.rs | 10 +- src/mux/codec/flac.rs | 2 - src/mux/codec/h264.rs | 58 +- src/mux/codec/hevc.rs | 143 ++-- src/mux/codec/lpcm.rs | 3 +- src/mux/codec/mod.rs | 37 +- src/mux/codec/mpeg2.rs | 16 +- src/mux/codec/mpegaudio.rs | 7 +- src/mux/codec/pesbuf.rs | 26 +- src/mux/codec/pgs.rs | 32 +- src/mux/codec/reorder.rs | 24 +- src/mux/codec/startcode.rs | 5 +- src/mux/codec/truehd.rs | 39 +- src/mux/codec/vc1.rs | 40 +- src/mux/demux_sink.rs | 55 +- src/mux/demux_thread.rs | 17 +- src/mux/disc.rs | 25 +- src/mux/driver.rs | 80 +- src/mux/ebml.rs | 24 +- src/mux/fmp4/mod.rs | 17 +- src/mux/fvi_sink.rs | 27 +- src/mux/hevc/mod.rs | 46 +- src/mux/m2ts.rs | 10 +- src/mux/m2ts_mux/mod.rs | 16 +- src/mux/m2ts_mux/packet.rs | 14 +- src/mux/meta_sink.rs | 2 - src/mux/mkv.rs | 250 +++---- src/mux/mkvstream.rs | 96 +-- src/mux/mod.rs | 9 +- src/mux/mp4/audio.rs | 23 +- src/mux/mp4/mod.rs | 94 +-- src/mux/mp4/read.rs | 236 +++--- src/mux/network.rs | 10 +- src/mux/pipelined_stream.rs | 8 +- src/mux/ps.rs | 87 +-- src/mux/resolve.rs | 326 ++++----- src/mux/resync.rs | 15 +- src/mux/select.rs | 14 +- src/mux/timeline.rs | 262 +++---- src/mux/ts.rs | 21 +- src/mux/tsmux.rs | 40 +- src/mux/videomap.rs | 84 +-- src/platform/fs_type/linux.rs | 5 +- src/platform/fs_type/mod.rs | 8 +- src/progress.rs | 7 - src/scsi/fd_handoff.rs | 120 +-- src/scsi/linux.rs | 12 +- src/scsi/macos.rs | 33 +- src/scsi/mod.rs | 47 +- src/scsi/windows.rs | 49 +- src/sector/bus_removal.rs | 43 +- src/sector/decrypting.rs | 4 - src/sector/mod.rs | 23 +- src/sector/prefetched.rs | 56 +- src/session.rs | 25 +- src/testlog.rs | 12 +- src/udf.rs | 130 ++-- src/unlock_bridge.rs | 4 +- tests/crypto_tests.rs | 3 +- tests/disc_tests.rs | 1 - tests/fvi_pipeline.rs | 2 +- tests/scsi_error_decoding.rs | 7 +- 319 files changed, 2739 insertions(+), 23490 deletions(-) rename docs/FVI_FORMAT.md => FVI_FORMAT.md (100%) delete mode 100644 docs/README.md delete mode 100644 docs/aacs-content.md delete mode 100644 docs/aacs-crypto.md delete mode 100644 docs/aacs-derive.md delete mode 100644 docs/aacs-resolve.md delete mode 100644 docs/aacs-trace.md delete mode 100644 docs/aacs.md delete mode 100644 docs/ac3.md delete mode 100644 docs/adts.md delete mode 100644 docs/api-design.md delete mode 100644 docs/architecture.md delete mode 100644 docs/au-assembly.md delete mode 100644 docs/bdmt.md delete mode 100644 docs/bdnav.md delete mode 100644 docs/bluray.md delete mode 100644 docs/bounded.md delete mode 100644 docs/byte-prefetcher.md delete mode 100644 docs/class-reader.md delete mode 100644 docs/clpi-audit.md delete mode 100644 docs/clpi.md delete mode 100644 docs/codec-mod.md delete mode 100644 docs/coding.md delete mode 100644 docs/consts.md delete mode 100644 docs/crc.md delete mode 100644 docs/criterion.md delete mode 100644 docs/css-keyless.md delete mode 100644 docs/css-mod.md delete mode 100644 docs/ctrm.md delete mode 100644 docs/dbp.md delete mode 100644 docs/decrypt.md delete mode 100644 docs/decrypting-sector-source.md delete mode 100644 docs/deluxe.md delete mode 100644 docs/demux-sink.md delete mode 100644 docs/demux-thread.md delete mode 100644 docs/diag.md delete mode 100644 docs/dirimage-tests.md delete mode 100644 docs/dirimage.md delete mode 100644 docs/disc-mod.md delete mode 100644 docs/disc-profile.md delete mode 100644 docs/disc-tests.md delete mode 100644 docs/disc-to-rip.md delete mode 100644 docs/drive-access.md delete mode 100644 docs/drive-linux.md delete mode 100644 docs/drive-mod.md delete mode 100644 docs/dropgate.md delete mode 100644 docs/dts.md delete mode 100644 docs/dvd-audio-probe.md delete mode 100644 docs/dvd.md delete mode 100644 docs/dvdnav-mod.md delete mode 100644 docs/dvdsub-mod.md delete mode 100644 docs/ebml.md delete mode 100644 docs/encode.md delete mode 100644 docs/encrypt.md delete mode 100644 docs/error-codes.md delete mode 100644 docs/extract.md delete mode 100644 docs/ffmpeg-validation.md delete mode 100644 docs/file-sector-source-linux.md delete mode 100644 docs/file-sector-source-macos.md delete mode 100644 docs/file-sector-source.md delete mode 100644 docs/flac.md delete mode 100644 docs/fmp4-mod.md delete mode 100644 docs/fox.md delete mode 100644 docs/fs-type-linux.md delete mode 100644 docs/fs-type.md delete mode 100644 docs/fsync.md delete mode 100644 docs/fvi-sink.md delete mode 100644 docs/h264.md delete mode 100644 docs/halt.md delete mode 100644 docs/harness.md delete mode 100644 docs/hddvd.md delete mode 100644 docs/hevc-mux.md delete mode 100644 docs/hevc.md delete mode 100644 docs/hex.md delete mode 100644 docs/identity.md delete mode 100644 docs/ifo.md delete mode 100644 docs/image-writer.md delete mode 100644 docs/index-bdmv.md delete mode 100644 docs/index-select.md delete mode 100644 docs/inf.md delete mode 100644 docs/io-mod.md delete mode 100644 docs/jar.md delete mode 100644 docs/key-provider.md delete mode 100644 docs/keysource.md delete mode 100644 docs/labels-mod.md delete mode 100644 docs/labels-xml.md delete mode 100644 docs/lfsr.md delete mode 100644 docs/lib-overview.md delete mode 100644 docs/local-file-sink.md delete mode 100644 docs/lpcm.md delete mode 100644 docs/m2ts-mux.md delete mode 100644 docs/m2ts.md delete mode 100644 docs/meta-sink.md delete mode 100644 docs/mkb.md delete mode 100644 docs/mkv-mux.md delete mode 100644 docs/mkvstream.md delete mode 100644 docs/mobj.md delete mode 100644 docs/mp4-audio.md delete mode 100644 docs/mp4-mux.md delete mode 100644 docs/mp4-read.md delete mode 100644 docs/mpeg2.md delete mode 100644 docs/mpegaudio.md delete mode 100644 docs/mpls-universal.md delete mode 100644 docs/mpls.md delete mode 100644 docs/mux-disc.md delete mode 100644 docs/mux-driver.md delete mode 100644 docs/mux-select.md delete mode 100644 docs/mux-timeline.md delete mode 100644 docs/nav.md delete mode 100644 docs/network-mod.md delete mode 100644 docs/paramount.md delete mode 100644 docs/pesbuf.md delete mode 100644 docs/pgs-forced-probe.md delete mode 100644 docs/pgs.md delete mode 100644 docs/pipeline.md delete mode 100644 docs/pipelined-stream.md delete mode 100644 docs/pixelogic.md delete mode 100644 docs/platform-macos.md delete mode 100644 docs/png_filenames.md delete mode 100644 docs/prefetched.md delete mode 100644 docs/progress.md delete mode 100644 docs/ps-mod.md delete mode 100644 docs/reorder.md delete mode 100644 docs/resolve.md delete mode 100644 docs/resync.md delete mode 100644 docs/rip-recovery.md delete mode 100644 docs/scsi-error-decoding.md delete mode 100644 docs/scsi-linux.md delete mode 100644 docs/scsi-macos.md delete mode 100644 docs/scsi-mod.md delete mode 100644 docs/scsi-windows.md delete mode 100644 docs/sector.md delete mode 100644 docs/segment-key.md delete mode 100644 docs/segment.md delete mode 100644 docs/session.md delete mode 100644 docs/sink.md delete mode 100644 docs/socket.md delete mode 100644 docs/startcode.md delete mode 100644 docs/tables.md delete mode 100644 docs/testlog.md delete mode 100644 docs/truehd.md delete mode 100644 docs/ts-demux.md delete mode 100644 docs/tsmux.md delete mode 100644 docs/udf.md delete mode 100644 docs/unlock-bridge.md delete mode 100644 docs/variant.md delete mode 100644 docs/vc1.md delete mode 100644 docs/videomap-mod.md delete mode 100644 docs/videomap.md delete mode 100644 docs/vm.md delete mode 100644 docs/vocab.md delete mode 100644 docs/writeback-file-linux.md delete mode 100644 docs/writeback-file-macos.md delete mode 100644 docs/writeback-file-windows.md delete mode 100644 docs/writeback-file.md delete mode 100644 docs/writeback-linux.md diff --git a/.github/workflows/comment-guard.yml b/.github/workflows/comment-guard.yml index f962514d..1440dfae 100644 --- a/.github/workflows/comment-guard.yml +++ b/.github/workflows/comment-guard.yml @@ -9,4 +9,4 @@ concurrency: jobs: comment-guard: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork - uses: freemkv/.github/.github/workflows/guard-comment.yml@v1 + uses: freemkv/.github/.github/workflows/guard-comment.yml@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa3c461..f28b7bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.7.7] — 2026-09-26 + +### Maintenance + +- Replace comment-overflow documentation with concise source contracts and README instructions; enforce the shared comment policy in CI. + +### Fixed + +- Preserve PGS clear events so forced subtitles disappear and FFmpeg remuxing retains valid timestamps (#52). +- Reassemble AAC/MPEG audio frames, recognize standard transport audio types, retain opening audio and preserve MKV decoder delay/padding. + +### Added + +- FFmpeg interoperability validation on QA, using generated fixtures and decoded-content/timing comparisons. + ## [1.7.6] — 2026-09-26 ### Changed diff --git a/Cargo.lock b/Cargo.lock index d15682d5..f9502188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,7 +221,7 @@ dependencies = [ [[package]] name = "freemkv-unlock" -version = "1.7.6" +version = "1.7.7" dependencies = [ "aes", "base64", @@ -317,7 +317,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfreemkv" -version = "1.7.6" +version = "1.7.7" dependencies = [ "aes", "base64", @@ -839,12 +839,12 @@ dependencies = [ [[patch.unused]] name = "freemkv-engine" -version = "1.7.6" +version = "1.7.7" [[patch.unused]] name = "freemkv-i18n" -version = "1.7.6" +version = "1.7.7" [[patch.unused]] name = "freemkv-keysources" -version = "1.7.6" +version = "1.7.7" diff --git a/Cargo.toml b/Cargo.toml index a0254349..9f0d65ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "1.7.6" +version = "1.7.7" edition = "2024" rust-version = "1.88" license = "MIT" diff --git a/docs/FVI_FORMAT.md b/FVI_FORMAT.md similarity index 100% rename from docs/FVI_FORMAT.md rename to FVI_FORMAT.md diff --git a/README.md b/README.md index 3bd2fbdc..c833d045 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require disc-specific Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top. -**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)** · **[Changelog](CHANGELOG.md)** +**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Changelog](CHANGELOG.md)** Part of the [freemkv](https://github.com/freemkv) project. @@ -63,15 +63,14 @@ output.finish()?; ### Multi-pass recovery rip -Recovery moved OUT of this crate in 1.6.0. The sweep/patch strategy, the -ddrescue mapfile, damage classification and the multipass loop now live in the +The sweep/patch strategy, ddrescue mapfile, damage classification and +multipass loop live in the `freemkv-engine` crate as `freemkv_engine::recovery::{copy, sweep, patch}`. libfreemkv keeps the layers underneath: the raw single-shot read (`Drive::read`) and the SCSI-fact translation (`SenseFamily`) that the engine's strategy is built on. The dependency runs engine → libfreemkv, so this crate -cannot call into it; front-ends get recovery from the engine directly. See -[`docs/rip-recovery.md`](docs/rip-recovery.md) for what stayed here. +cannot call into it; front-ends get recovery from the engine directly. ## What It Does @@ -131,7 +130,8 @@ Streams — unified PES pipeline └── NullStream — discard sink ``` -See [docs/](docs/) for detailed technical documentation on each module. +Build API documentation with `cargo doc --no-deps --open`. The public +[FVI format specification](FVI_FORMAT.md) describes exported video indexes. ## Error Codes @@ -187,3 +187,27 @@ The minimum supported Rust version (MSRV) is **1.88**, declared as ## License MIT + +## Validation + +Run `cargo test --tests`, `cargo fmt --check`, and +`cargo clippy --all-targets -- -D warnings` before pushing. + +The FFmpeg interoperability workflow runs on `qa` pushes or manual dispatch. +With FFmpeg/ffprobe installed, run: + +```sh +cargo test --release --lib ffmpeg_ -- --ignored --nocapture +``` + +It generates synthetic PGS/video/audio fixtures and checks decoded content, +timing and decoder warnings. Set `FREEMKV_FFMPEG_ARTIFACT_DIR` to retain local +fixtures; CI retains fixtures and logs for 14 days. Real-disc QA is separate. + +FFmpeg runs as an installed executable; we do not link or distribute its +binaries. This use is permitted by its software licenses; codec patent rights +are separate. See [FFmpeg legal guidance](https://ffmpeg.org/legal.html). + +MKV remuxing preserves decoder delay and frame padding locally. Network/stdio +PES serialization does not carry that metadata. Direct `PesFrame` constructors +must set `discard_padding_ns` to zero unless preserving trimming. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 551463e4..add93566 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -139,7 +139,7 @@ If your machine has a free SATA port, use it. ### How freemkv Handles This -freemkv uses a three-layer recovery model. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for full details. +freemkv uses a three-layer recovery model. - **Pass 1 (`freemkv_engine::recovery::sweep`):** Fast sweep with 64 KB reads. On failure, zero-fills the block and skips forward. Writes a ddrescue-format mapfile for later retry. - **Pass 2+ (`freemkv_engine::recovery::patch`):** Targeted re-reads of bad ranges with a long 60-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window. @@ -164,7 +164,7 @@ Workarounds: - **Accept the partial ISO.** freemkv's skip-forward recovery will zero-fill the unreadable blocks and continue. The resulting ISO may be playable with minor glitches in the affected areas. - **Physical replug between retry passes.** If running multi-pass patch, replug the drive between passes to clear the taint state. -freemkv deliberately does not attempt inline SCSI resets or eject cycles to recover from this state, because those operations were found to make the problem worse on affected hardware (see the design rationale in [`docs/rip-recovery.md`](docs/rip-recovery.md)). +freemkv deliberately does not attempt inline SCSI resets or eject cycles to recover from this state, because those operations were found to make the problem worse on affected hardware. --- diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 6e355c97..00000000 --- a/docs/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# libfreemkv Documentation - -Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv), the open source optical drive library. - -## Start Here - -**[Disc to Rip: End-to-End Flow](disc-to-rip.md)** — How the library goes from a disc in the drive to decrypted content. Read this first. - -## Reference - -| Document | What it covers | -|----------|---------------| -| [FFmpeg QA validation](ffmpeg-validation.md) | Synthetic decoder interoperability gate, regressions, licensing | -| [Architecture](architecture.md) | Module map, design principles, error codes, platform support | -| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed | -| [Rip Recovery](rip-recovery.md) | What this crate owns of the recovery model: single-shot Drive::read, SenseFamily, DiscStream batch halving (the strategy itself moved to freemkv-engine in 1.6.0) | -| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake | -| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc | -| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types | -| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation | -| [API Design](api-design.md) | Stream API design, PES pipeline, input/output resolution | - -## Reading Order - -If you want to understand the whole library: - -1. **[Disc to Rip](disc-to-rip.md)** — the big picture -2. **[Architecture](architecture.md)** — how modules fit together -3. **[Drive Access](drive-access.md)** — how we talk to hardware -4. **[UDF](udf.md)** → **[MPLS](mpls.md)** → **[CLPI](clpi.md)** — how disc content is structured -5. **[AACS](aacs.md)** — how encryption works and how we break it - -## API Documentation - -Generated API docs are on [docs.rs/libfreemkv](https://docs.rs/libfreemkv). diff --git a/docs/aacs-content.md b/docs/aacs-content.md deleted file mode 100644 index fdb97ca4..00000000 --- a/docs/aacs-content.md +++ /dev/null @@ -1,190 +0,0 @@ -# AACS Content Decryption — Implementation Notes - -Rationale and background trimmed out of `src/aacs/content.rs` doc comments to -satisfy `ci/comment-guard.py`'s per-audience prose caps. See -[AACS Encryption](aacs.md) for the public-facing key resolution story and -[AACS Crypto Primitives](aacs-crypto.md) for the low-level AES primitives. - -## `is_unit_aligned` - -AACS aligned units (6144 B / 3 sectors) are anchored at the start of each -clip's encrypted region, so a read must begin a whole number of units past -that base for `decrypt_sectors` (which anchors units at buffer offset 0) to -align the CBC correctly. This is the single source of truth for the -decrypt-on-read gate, the inline and highway mux read paths, and the -key-validation sample reader — all key off this, never absolute `lba % 3`. A -disc whose clip `start_lba` is not itself 3-aligned would otherwise mis-gate -(reject readable units, then report "Decryption failed") on exactly the -titles whose clips land off a 3-boundary. - -`saturating_sub` makes the `lba < unit_base` case well-defined — it clamps -the offset to 0, a unit boundary — rather than the latent `wrapping_sub` trap -where an underflow wraps to ~2^32 and, because `2^32 ≡ 1 (mod 3)`, -mis-reports the alignment (e.g. `lba == unit_base - 1` would falsely read as -aligned). - -## `aacs_unit_encrypted` - -The mux is the principal consumer of this signal for `BdTs` discs (via -[`is_clean`], see below). It is readable WITHOUT a key. CRITICAL: only -meaningful when `unit` is read at the correct clip-FILE-anchored boundary — a -disc-absolute / mis-aligned read makes the flag byte arbitrary mid-stream -data, which is why per-unit checks run clip-anchored, not in the whole-disc -sweep. - -## `aacs_unit_needs_decrypt` - -Buffer-iterating sites that may run twice over the same `buf` (the post-fetch -re-decrypt, sample collection, failure diagnosis) need an IDEMPOTENT "does -this still need work?" test, so `aacs_unit_encrypted` (the authoritative flag, -which decryption never rewrites) is composed with a "structure restored?" -check that flips once decrypted: TS syncs come back for `BdTs`; valid -`00 00 01 BA` packs come back for `MpegPs`. - -## HD-DVD scramble flag (`PS_SCRAMBLE_OFF` / `PS_SCRAMBLE_MASK`) - -BD/UHD/FMTS flag encryption uses the Copy Permission Indicator in the top 2 -bits of byte 0 (the M2TS `TP_extra_header`). HD-DVD `.evo` is Program Stream — -byte 0 is a `00 00 01 BA` pack_start_code, NOT a CPI — so AACS reuses the -MPEG-2 `PES_scrambling_control` field instead: pack_header (14 bytes) + PES -start-code/`stream_id` (4) + `PES_packet_length` (2) puts the PES flags byte at -offset 20, with `PES_scrambling_control` in bits 5-4 (`& 0x30`). Non-zero = -encrypted. Derived from BackupHDDVD (`Header[20] & 0x30`) cross-checked -against MPEG-2 systems (ISO/IEC 13818-1). - -UNVERIFIED against a real ENCRYPTED HD-DVD disc — none available to confirm -byte-exactly. Two open questions a real disc must settle: -1. A pack carrying an MPEG `system_header` (`00 00 01 BB`) before the PES - packet shifts this offset past 20. -2. Whether offset 20 is even readable pre-decrypt. BD keeps only the first 16 - bytes clear (the seed) and AES-CBC-encrypts 16..6144 — under that model - offset 20 is ciphertext. BackupHDDVD reading `Header[20]` pre-decrypt - implies HD-DVD instead encrypts PES *payloads* with *clear* pack/PES - headers (per-PES model). If so, `decrypt_unit`'s 16-byte-seed model also - would not fit HD-DVD and needs its own path. TS is unaffected either way. - -## `KEY_PROOF_PACKETS` - -Four `0x47` syncs are 32 bits of MPEG-TS structure, but the per-UNIT -false-pass risk is NOT 2^-32: `is_clean_ts` accepts ANY four of the ~31 -encrypted packets in a 6144-byte aligned unit, so for a wrong key (uniform -AES noise, `0x47` at 1/256 per packet) it is ≈ C(31,4)·256^-4 ≈ 7e-6, i.e. -~1e-5. 1-in-4-billion is the probability for four SPECIFIC packets and -overstates the margin by ~4000x; at `KEY_PROOF_PACKETS = 3` the per-unit rate -is ≈ C(31,3)·256^-3 ≈ 2.6e-4, so do NOT lower it on the strength of slack -that is not there. It is an ABSOLUTE proof floor, NOT a proportion — a unit -the key opened but whose content is bad-encoded (many non-conforming -packets) is proven by ANY four good packets, not rejected for the bad ones. - -## `is_clean_ts` - -The mux is its principal consumer for `BdTs` discs, reaching it through -`is_clean`: `mux::resolve`'s multi-CPS `pick` closure selects a unit key by -it, `probe_index_phase` reports each FMTS index's interleave parity by it, -and `decrypt::decrypt_sectors_mapped` uses it as the forensic-range verify -net. (The doc used to say "the mux never calls this", which invited a -maintainer to tighten or loosen the proof rule believing only whole-disc read -verification was affected — while it in fact changes which unit key a -multi-CPS disc muxes with and which phase an FMTS index is muxed at.) - -Rule — evidence is ABSOLUTE, scaled to the packets that exist. Over the -ENCRYPTED packets (skip packet 0: its `0x47` sits in the clear 16-byte seed, -so it reads `0x47` for ANY key and is never evidence), let `E` = non-padding -content packets and `synced` = those carrying `0x47`. The key opened the unit -iff `E == 0` (nothing encrypted to prove) OR `synced >= min(E, KEY_PROOF_PACKETS)`. -* WRONG key → ~0 synced → fails (reaching 4 by chance ≈ 1e-5/unit, and every - unit of a clip would have to fluke — astronomically safe). -* RIGHT key, bad-encoded content → any 4 good packets pass; the bad ones are - the muxer's problem. (This is the false-negative the old 75% PROPORTION - caused — a mostly-bad unit the key opened was wrongly rejected.) -* `min(E, 4)` handles the end-of-clip fragment TAIL: a unit with only E=1 - real packet (then source-zero padding) needs just that one to sync, so a - sparse-but-valid tail is never false-rejected. Padding (all-zero payload) - is excluded throughout. - -## `is_clean_ps` - -The Program-Stream arm of `is_clean` (HD-DVD `.evo`): every 2048-byte pack -begins with the pack_start_code `00 00 01 BA`; a 6144-byte AACS unit spans -three packs. Pack 0's start sits in the clear 16-byte seed (present -regardless of the key — mirrors `is_clean_ts` skipping packet 0); packs 1 -and 2 (offsets 2048 and 4096) are in the encrypted region, so a wrong key -garbles them and this returns false (64 bits of discrimination). Validated -against real decrypted HD-DVD `.evo` (two real retail titles): pack starts -are exactly 2048-aligned, three per aligned unit, at offsets 0 / 2048 / 4096. - -UNVERIFIED-HDDVD-DECRYPT: the pack structure here is confirmed on decrypted -rips, but that a real ENCRYPTED `.evo` decrypts to it via the same -6144-byte aligned unit (16-byte seed + AES-CBC over 16..6144) as BD is the -unverified assumption — we have no encrypted HD DVD. If HD-DVD decryption -yields garbage with a known-good key, the unit granularity is the suspect. - -## `decrypt_unit` / `encrypt_unit` - -Block Key = AES-128E(Kcu, seed) ⊕ seed (`[BD]` §3.10.1 Fig 3-8: encrypt the -clear 16-byte seed under the CPS Unit Key, XOR the seed back in — the -trailing ⊕ seed is load-bearing); then AES-128-CBC decrypt/encrypt bytes -16..6144 under the AACS IV. `encrypt_unit` is the exact inverse of -`decrypt_unit`, for authoring an encrypted disc image (and for building -genuinely-encrypted read-path fixtures); it does NOT set the encrypted flag, -because where that flag lives is container-specific, and keeping it out -keeps the crypto container-agnostic. - -Source-zero padding packets (all 192 bytes zero on disc) are restored to -zero by `decrypt_unit`: their decrypted bytes are AES-noise from decrypting -zeros, but the source WAS zero, so writing the true source back is faithful -and gives the demux a tidy gap. Content packets are left EXACTLY as -decrypted — the decrypt path never rewrites content, so an authored-bad -packet passes through verbatim. `encrypt_unit` deliberately does NOT mirror -this: an all-zero plaintext packet encrypts to ciphertext that is not -all-zero, so it is not mistaken for padding on the way back and the round -trip is still exact. Authoring that wants true source-zero padding leaves -those packets unencrypted instead. - -`encrypt_unit` returns `false` — encrypting nothing — when `unit` is shorter -than `ALIGNED_UNIT_LEN`. That case MUST be checked: the caller has already -set the container's encrypted flag by then (the contract requires -flag-before-crypto, since the header is the key seed), so ignoring the -result leaves a unit marked encrypted while still carrying plaintext, which -is the worst possible outcome for an authoring tool. - -## `decrypt_bus` key-schedule regression (test: `decrypt_bus_expands_the_read_data_key_once_per_unit`) - -MEASURED, not reasoned: `decrypt_bus` used to call `aes_cbc_decrypt` once per -2048-byte sector, and each call built its own AES-128 key schedule, so a -6144-byte aligned unit performed THREE key expansions under the same -loop-invariant `read_data_key`. On a 90 GB UHD read on a stock -(non-firmware-unlocked) drive — ~14.6 million aligned units — that is ~29 -million redundant expansions on the per-unit decrypt hot path, for a key -that is constant for the whole disc. The counter is incremented inside -`crypto::new_cipher`, the single construction site. - -## `IV0_PUBLISHED` (test fixture) - -`[C]` §2.1.2 fixes one default CBC IV for every AACS AES-CBC operation. Both -IV tests used to compute their expected value from `crypto::AACS_IV` itself, -so the constant was asserted against itself and NOTHING in the suite pinned -its bytes: swapping `AACS_IV` for `[0u8; 16]` left both tests passing (one -builds its ciphertext with the same value and the other cancels the change -in a triple XOR) while every real AACS disc decrypted to noise — block 0 of -every 6144-byte aligned unit and of every bus-encrypted sector XORed with the -wrong IV. `IV0_PUBLISHED` is the independent witness the tests assert -against instead. - -## Encrypted-flag readers (`aacs_unit_seed_encrypted`, `aacs_unit_encrypted`) - -`aacs_unit_seed_encrypted` is the flag reader for a PARTIAL unit — the guard -that stops a truncated encrypted fragment from being emitted as clear -content. It reads ONLY the two Copy Permission Indicator bits (`[BD]` -§3.10.2, byte 0 bits 6-7); the remaining six bits are `TP_extra_header` -arrival-timestamp bits and carry no encryption meaning. Both failure -directions are damaging and silent: a reader that answers "encrypted" for a -clear fragment discards good content, and one that answers "clear" for an -encrypted fragment writes ciphertext into the output as if it were video. - -`aacs_unit_encrypted` is the AUTHORITATIVE gate and requires a WHOLE -6144-byte aligned unit: on anything shorter the flag byte is not guaranteed -to be the unit's, so it must answer `false` and leave the partial-unit case -to `aacs_unit_seed_encrypted`. A reversed length guard would both classify -fragments off arbitrary mid-stream bytes and, on an empty slice, index out -of bounds. diff --git a/docs/aacs-crypto.md b/docs/aacs-crypto.md deleted file mode 100644 index 0084fd3a..00000000 --- a/docs/aacs-crypto.md +++ /dev/null @@ -1,89 +0,0 @@ -# AACS Crypto Primitives — Implementation Notes - -Rationale and background for the internal (non-public) primitives in -`src/aacs/crypto.rs`. See [AACS Encryption](aacs.md) for the public-facing -key resolution and decryption story. - -## `aes_cbc_encrypt` - -Forward direction of `aes_cbc_decrypt`, and its exact inverse (`[C]` §2.1.2, -`AES-128CBCE`). `data.len()` must be a multiple of 16; the `debug_assert!` -documents/enforces that contract. - -Constructs the cipher ONCE for the whole slice. Driving this from the -single-block `aes_ecb_encrypt` instead rebuilds the AES key schedule per -16-byte block, which for a 6144-byte aligned unit is 383 redundant key -expansions. - -## `aes_cbc_decrypt` - -AES-128-CBC decrypt in-place with the fixed AACS IV (`[C]` §2.1.2, -`AES-128CBCD`). `data.len()` must be a multiple of 16; any trailing partial -block is silently ignored. All callers pass aligned regions (6128 and 2032 -bytes), and the `debug_assert!` documents/enforces that contract. - -This doc was once orphaned onto `aes_cbc_encrypt` above it, when that -function was inserted directly after it with no separating blank line: -rustdoc rendered the crate's only forward-direction AACS primitive as -"decrypt" and cited the spec's DECRYPT clause for it, while this function -had no doc at all. `encrypt_unit_is_the_exact_inverse_of_decrypt_unit` in -`content.rs` pins the directions behaviourally, so a maintainer "fixing" the -contradiction by swapping the two bodies fails the suite instead of shipping -a second decryptor behind an already-set encrypted flag. - -## `cbc_decrypt_blocks` - -Split out of `aes_cbc_decrypt` so a caller that decrypts several regions -under one loop-invariant key expands the schedule once. `decrypt_bus` -(`super::content::decrypt_bus`) is that caller: bus encryption (`[C]` §4.2 / -the AACS 2.0 Read Data Key) covers bytes 16..2048 of EVERY 2048-byte -sector, so a 6144-byte aligned unit is three regions under one -`read_data_key` — three key schedules where one suffices, on the per-unit -decrypt hot path of a whole 90 GB read. - -## `aes_g` - -AES-G(x1, x2) = AES-128D(x1, x2) XOR x2 (`[C]` §2.1.3, note: uses -AES-128**D**). The Media Key Variant chain uses AES-G to derive both the -variant number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key -(`Kvu = AES-G(Km, VID)`). See `super::derive::derive_vuk` for the classical -VUK form — the math is identical, this exposes it as a neutral primitive -for the variant chain. - -## `aesg3` - -AACS-G3 (`[C]` §3.2.2, Triple AES Generator): `left = D(k,s0)⊕s0` inc 0, -`pk = D(k,s0+1)⊕(s0+1)` inc 1, `right = D(k,s0+2)⊕(s0+2)` inc 2. Shared -with `super::variant` (its variant chain runs the same SD tree); a single -definition keeps the two walks byte-identical. - -## Test: `S0` - -The AACS-G3 seed `s0`, transcribed independently from `[C]` §3.2.2 rather -than read from `AESG3_SEED` — a test that sourced the seed from the -production constant would assert that constant against itself and would -still pass if it were edited. - -## Test: `aesg3_inverts_to_the_spec_seed_under_aes_encrypt` - -`aesg3` is the node function of the AACS subset-difference tree: every -Processing Key the DK walk produces (`aesg3(node_key, 1)`) and every descent -step (`aesg3(., 0)` / `aesg3(., 2)`) is one call. A body that returned a -fixed block would make every device key in the crate derive the SAME -Processing Key, and a `^` that became `|` or `&` would derive a -wrong-but-plausible one — in both cases the MKB walk simply stops finding -Media Keys, with no error to say why. - -Pinned through the spec relation rather than a re-implementation: `[C]` -§3.2.2 defines `AES-G3` as `AES-128D(k, s) XOR s` for `s = s0 + inc` (added -into the last seed byte), so applying the FORWARD primitive -`aes_ecb_encrypt` — a different function from the one under test — to -`aesg3(k, inc) XOR s` must reproduce `s` exactly. - -## Test: `aesg3_yields_three_distinct_subkeys_for_the_three_increments` - -The Triple Generator's three outputs (`[C]` §3.2.2: left = inc 0, the -Processing Key = inc 1, right = inc 2) are the two child node keys and the -Processing Key of ONE tree node. They must be three different keys — if -`inc` were ignored, a descent would revisit its own parent and the walk -would derive the same key at every level of the tree. diff --git a/docs/aacs-derive.md b/docs/aacs-derive.md deleted file mode 100644 index b3f61825..00000000 --- a/docs/aacs-derive.md +++ /dev/null @@ -1,68 +0,0 @@ -# `src/aacs/derive.rs` — key derivation notes - -Rationale and background relocated here from doc comments in `derive.rs` to -keep those comments within the repo's comment-length limits. - -## `derive_media_key_from_pk` — why the direct PK × cvalue scan - -A Processing Key is **terminal**: it is the key at its Subset-Difference -node, one `AES-G` from the Media Key. So the fast path tries each PK -*directly* against the MKB cvalue tables (no tree descent). On a large -AACS 2.x UHD MKB (~181k cvalues) this is ~15x faster than treating a PK as -a device-node label and walking the tree. - -MKB record types relevant to this path: - -| Type | Meaning | -|------|---------| -| `0x10` | Type and Version Record (has MKB version) | -| `0x81` | Verify Media Key Record, AACS 1.0 (has `mk_dv`) | -| `0x86` | Verify Media Key Record, AACS 2.0/2.1 (has `mk_dv`) | -| `0x04` | Subset-Difference Index (has UVS entries) | -| `0x05` | Media Key Data Record (cvalues, 1:1 with `0x04`) | -| `0x07` | Explicit Subset-Difference Record (NOT cvalues) | - -## `recover_dk_position` — search strategy and cost - -This finds a device key's tree node empirically: for each MKB -subset-difference record, it tries the device at the record's node AND at -every ancestor v-position (the device may sit one or more levels ABOVE the -record, descending via AES-G3 to reach it), deriving the candidate -Processing Key DIRECTLY (one `calc_pk_from_dk` per candidate, no full -re-walk) and checking it validates against that record's cvalue. - -Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference -index, not the quartic cost of re-deriving per candidate. Slots are -independent so the scan parallelizes (~181k slots, ~26s serial on a UHD -MKB); `find_map_any` returns the first match and cancels the rest. - -## `resolve_candidate` — composed primitives and `None` conditions - -Composes the raw derivation primitives (`derive_media_key_from_pk`, -`derive_media_key_and_pk_from_dk`, `derive_vuk`, `derive_unit_keys`) and -parses `Unit_Key_RO.inf` at the version the disc's MKB declares, so a -multi-CPS disc yields all its unit keys from the one candidate. - -Validate `unit_keys` against a real encrypted unit with `decrypt_unit` + -`is_clean_ts` to prove the candidate actually opens the disc — this function -is pure derivation, no sampling or validation. - -Returns `None` only when derivation itself cannot proceed: a PK its MKB -rejects, a `Dk` the MKB can't process, a missing VID on a path that needs -one, or an unparseable/empty `Unit_Key_RO.inf`. - -## Test fixtures — inverting the AACS relations - -There are no published AACS test vectors, but none are needed: the AACS -relations (`[C]` §3.2.3–§3.2.5) are invertible, so a valid MKB for a CHOSEN -key can be constructed with `aes_ecb_encrypt` and the same `aesg3` the walk -uses as its node function. `plant_mkb` and its siblings in -`position_recovery_tests` do exactly this — no real key material, and the -assertions check the DERIVED Media Key, not any intermediate the code under -test also produces. - -`plant_mkb`'s single-slot fixture picks `uv = 0x0400` (lowest set bit 10) -with `u_mask_shift = 12` so a gating device node exists: `resolve_dk_node` -flips one bit `b < 12`, and the walk's gate (`[C]` §3.2.4) needs that bit -inside `v_mask` (`0xFFFF_FFFF << 11`) and outside `u_mask` -(`0xFFFF_FFFF << 12`) — i.e. `b == 11`. diff --git a/docs/aacs-resolve.md b/docs/aacs-resolve.md deleted file mode 100644 index e4d7403d..00000000 --- a/docs/aacs-resolve.md +++ /dev/null @@ -1,78 +0,0 @@ -# `src/aacs/resolve.rs` — internal rationale - -Overflow detail relocated here by the comment-guard pass; each section is -pointed to by a short `//` comment at the named site in `resolve.rs`. - -## `resolve_keys_classical` - -Resolve all AACS keys for a disc using the classical (single-stage Media Key -derivation) paths. Used by both V10 and V20. - -Paths run in root-of-trust -> per-disc-leaf order. A match at any path -returns immediately: - -1. MKB + device keys -> processing key -> media key -> VUK -2. MKB + processing keys -> media key -> VUK -3. KEYDB MK + matching VID -> derived VUK -4. KEYDB disc-hash -> VUK -5. KEYDB disc-hash -> pre-decrypted unit keys (no VUK) - -## `unique_verifying_mk` - -The MK-pool selection rule of path 2.5, split out of `resolve_keys_v1` so the -ambiguity guard has a reachable test. - -`verifies` is the MKB check -- in production -`AES-D(mk, mk_dv)[..8] == MK_VERIFY_MAGIC`. Returns a Media Key only when -EXACTLY ONE DISTINCT candidate passes. Duplicates of the same key are one -candidate (a pool aggregated across providers routinely repeats a key), but -two DIFFERENT keys that both verify mean the pool cannot say which is this -disc's Km: picking either derives a wrong VUK, and a wrong VUK decrypts to -plausible-looking garbage rather than failing loudly. Bail and let the later -hash/VID paths answer instead. - -The predicate is a parameter rather than the inlined AES check because a -genuine two-key multi-hit cannot be synthesised: it needs one ciphertext -that decrypts under two distinct AES-128 keys to plaintexts sharing a 64-bit -prefix -- a 2^64 search. Injecting the verifier is the only way the -ambiguity branch is reachable from a test at all. - -## Test: `mk_pool_ambiguity_bails_rather_than_picking_a_media_key` - -Path 2.5's ambiguity guard: when MORE THAN ONE DISTINCT pooled Media Key -verifies against the MKB, the resolver must return no key at all rather than -pick one. A wrong Km derives a wrong VUK, and a wrong VUK does not fail -loudly -- it decrypts the title to garbage that muxes and plays as a corrupt -rip. - -The real MKB check cannot be forced into a multi-hit: two distinct AES-128 -keys decrypting one `mk_dv` to plaintexts that share the 64-bit verify magic -is a 2^64 search, not a fixture. So the rule is tested through -`unique_verifying_mk`, whose verifier is a parameter -- the same function -`resolve_keys_v1` calls, with the same pool semantics. - -## Test: `resolve_keys_v21_treats_the_all_zero_volume_id_as_no_vid` - -`resolve_keys_v21` gates paths 1 and 3 on `has_vid`, and an all-zero Volume -ID is the crate's "the VID was never read" sentinel -- the SCSI handshake -leaves the buffer zeroed when it does not run or fails. - -Both directions matter and both fail silently: - -- treating the zero sentinel as a real VID runs path 3 and derives - `Kvu = AES-G(Km, 0...0)`, a perfectly well-formed but WRONG VUK. It - unwraps the title keys to garbage, and nothing downstream errors -- the - rip just decodes to noise. -- treating a real VID as absent skips paths 1 and 3 entirely, so a disc - that could have been resolved from its Media Key reports no key. - -Asserted through the final VUK, not through the flag. - -## Test: `classify_vid_present_with_material_is_no_material_not_vid` - -A NON-zero VID present, but resolution still fails (the providers carry -material that doesn't resolve this disc). The VID is available, so the -failure is NOT "VID unavailable" -- it must classify NoMaterial regardless -of how much derivation material is present, because re-acquiring the VID is -not the fix. This is the `has_vid == true` short-circuit, which no existing -test covers (the gate test only uses the zero-VID sentinel). diff --git a/docs/aacs-trace.md b/docs/aacs-trace.md deleted file mode 100644 index bc0f2af5..00000000 --- a/docs/aacs-trace.md +++ /dev/null @@ -1,44 +0,0 @@ -# AACS resolution trace shape - -`src/aacs/trace.rs` defines the structured trace of a resolution attempt (the -unlock phase, then the key-resolution phase). - -- No user-facing English: every step's STATE is a typed enum variant. - Applications render these into localized text — the library never does. - This module only defines the shape and is wired through the - resolve/handshake return path far enough to compile. -- The `who` of each step is the source's `label()` / unlocker's `name()` — a - stable identifier string (a NAME, like a codec id, NOT user-facing prose), - carried verbatim so an app renderer never has to match an enum back to a - name it already has. Only the OUTCOME / path enums are structured states - the app maps to i18n English. - -## `KeyStep` — a source's contribution to the key phase - -Each source that ran contributes one `KeyStep`: `who` (its `label()`), `path` -(the `Vec` it walked), and `outcome` (`KeyOutcome`). Two fields -de-conflate a keyless result — WHY a matched disc produced no usable key: - -- `matched_entry: Option` — the shape of the source's MATCHED - entry when it matched this disc; `None` for a true miss or a source kind - with no such shape. An app MAY log it verbatim and render a - matched-but-no-key verdict. -- `store_entries: Option` — per-disc entries loaded in the source's - store, when known, so a true-miss verdict can name the store size (`… not in - keydb (N entries loaded)`). `None` for a source that carries no such count. - -## `MatchedEntry` — booleans-and-lengths, never material - -A shape summary of a matched entry — safe to print at any log level, no key -MATERIAL: `has_vuk`, `has_unit_keys`, `unit_keys_len`, `has_media_key`, -`has_keydb_vid`, `enc_title_keys_len`, `vid_available`. It answers "the disc -WAS found — so why no key?" (e.g. a Media Key present but no VID on this path). - -## `KeyNode::NoDerivableKey` - -A derivation-path node distinct from `NoEntry`: the source MATCHED this disc -but could not derive any usable key from the matched entry (no derivation -material for the path taken). `NoEntry` = the disc was not found at all; -`NoDerivableKey` = found, but the key was not — the node that turns a flat -`no entry` verdict into `matched disc > … > NO KEY`. See docs/keysource.md -(`resolve_unit_keys`) for how a source populates it. diff --git a/docs/aacs.md b/docs/aacs.md deleted file mode 100644 index 309f86e0..00000000 --- a/docs/aacs.md +++ /dev/null @@ -1,216 +0,0 @@ -# AACS Encryption Support - -## Overview - -AACS (Advanced Access Content System) is the encryption layer used by Blu-ray -and UHD 4K discs to protect content. libfreemkv implements AACS decryption so -disc access is transparent to the application. - -There are two major versions: - -- **AACS 1.0** -- Used by standard Blu-ray discs. -- **AACS 2.0 / 2.1** -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus - encryption layer on top of the standard content encryption. UHD drives accept - AACS 1.0 host credentials for backward compatibility. - -All versions use AES-128 for content decryption. The library reads the keys it -needs from `keydb.cfg`, walks the disc's Media Key Block (MKB) to resolve the -disc's key, and decrypts the content stream. AACS-encrypted discs therefore -require a `keydb.cfg`; CSS-protected DVDs do not (see the CSS notes in the -library docs). - -## How it works (feature level) - -When a disc is scanned, the library: - -1. Reads the disc's AACS key-input files from the `/AACS/` directory. -2. Resolves the disc's key from `keydb.cfg` — either directly from a per-disc - entry, or by walking the MKB with the keys present in the keydb. -3. Performs the drive-level SCSI authentication handshake needed to obtain the - Volume ID and, for UHD, the bus-decryption key. -4. Decrypts the content stream as titles are read. - -A resolved key is verified against actual disc content before it is applied, so -a stale or wrong key fails loudly rather than producing silent garbage. If no -usable key is available for an AACS-encrypted disc, the library surfaces a -specific error (the E70xx family) describing which part of the chain was -missing, and a missing `keydb.cfg` surfaces as `Error::KeydbLoad` with the -sentinel path ``. - -## API Usage - -AACS decryption is transparent to the application. `Disc::scan()` handles -everything automatically: - -```rust -use libfreemkv::{Drive, Disc}; -use libfreemkv::disc::ScanOptions; -use std::path::Path; - -let mut drive = Drive::open(Path::new("/dev/sg4")).unwrap(); -drive.wait_ready().unwrap(); -drive.init().unwrap(); -let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap(); - -// Check encryption state -if disc.encrypted { - if let Some(ref aacs) = disc.aacs { - println!("AACS {}.0", aacs.version); - println!("Key source: {}", aacs.key_source.name()); - if let Some(mkb_ver) = aacs.mkb_version { - println!("MKB version: {}", mkb_ver); - } - } else { - println!("Encrypted but keys not available"); - } -} - -// Read content -- decryption is applied on read by the DiscStream decorator. -// Live disc does NOT go through the URL resolver: `input("disc://...")` returns -// Error::DiscUrlNotDirect by design. -let keys = disc.decrypt_keys(); -let mut stream = DiscStream::new( - Box::new(drive), - disc.titles[0].clone(), - keys, - batch_sectors, - disc.titles[0].content_format, - false, // raw: false → decrypt on read - None, // halt -)?; -while let Ok(Some(frame)) = stream.read() { - // decrypted PES frames -} -``` - -The application never calls decryption functions and never manages the -drive-level handshake. It DOES own key resolution — see below. - -### Key resolution is the caller's job - -`libfreemkv` is **lookup-free: it resolves no keys and reads no keydb.** There is -no `ScanOptions::with_keydb`, and `ScanOptions` has no keydb field — its only -scan input is the optional drive credentials for the live-drive authenticated -handshake. - -The caller resolves a key out-of-band through a key source and applies it with -[`Disc::decrypt_with`]. `freemkv-keysources` is the crate that implements the -keydb and key-server sources; `ScanOptions::key_sources` takes them as -`Box`. - -### AacsState - -After a successful scan, `disc.aacs` contains an `AacsState`: - -| Field | Type | Description | -|-------|------|-------------| -| `version` | `u8` | AACS version (1 or 2) | -| `bus_encryption` | `bool` | Whether bus encryption is active | -| `mkb_version` | `Option` | MKB version from disc | -| `disc_hash` | `String` | Identifier for the disc's key-input files | -| `key_source` | `KeyOrigin` | How the disc's key was resolved | - -## keydb.cfg - -`keydb.cfg` is the single source of AACS key material. It is a text file (lines -starting with `;` or `#` are comments) holding the host credentials and per-disc -entries the library uses to resolve a disc. autorip can auto-download and -refresh it from a configured URL. The library does not ship any AACS keys -compiled into the binary. - -## `UnitKey::is_default_index` test rationale - -`is_default_index` is the public predicate that separates ordinary (index-0) -content keys from FMTS forensic index keys (see `UnitKey` docs; AACS 2.1 -`IndividualSegment.tbl` tagging). A body answering `true` for everything would -present a forensic index key as an ordinary content key -- the caller would -decrypt the bulk of the title with a key that only opens 1/32nd of it; -answering `false` for everything would hide every ordinary key. - -`is_default_index_separates_the_two_constructors` is pinned against the two -named constructors, which are the contract: `UnitKey::new` builds the ordinary -key, `UnitKey::forensic` builds an index key for `1..=32`. - -`is_default_index_agrees_with_the_forensic_index_resolver` checks the -predicate agrees with the one consumer of `index_number` in the crate: -`crate::aacs::index_select::resolve_disc_index` resolves the disc's forensic -index from exactly the keys that are NOT default. If the two disagree, a disc -resolves an index whose key the rest of the pipeline treats as ordinary (or -vice versa). - -## `src/aacs/mod.rs` module notes - -### KEYDB.cfg line format - -``` -| DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x... -| PK | 0x... -| HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... -| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x... -0x = | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys> -``` - -### Spec provenance tags - -The crypto in `src/aacs/` carries `[TAG] §x.y` citations back to the published -AACS specification (Final Rev 0.953), so each primitive links to the section it -implements: - -- `[C]` -- AACS Introduction and Common Cryptographic Elements Book (primitives, - MKB/key-management). -- `[PR]` -- AACS Pre-recorded Video Book (Volume/Title Key layer). -- `[BD]` -- AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, - Block Key). -- `[RE]` -- reverse-engineered from real discs, cited only where the public - spec is silent (the `0x86` verify record and the Category-C MKB type values). - -### AACS key-input path discovery (`AacsRole`, `role_paths`, `find_hddvd_aacs_dir`) - -BD and UHD keep their key material under a fixed `/AACS/...` tree, so those -paths are constants. HD DVD keeps the equivalents in a reserved root directory -whose NAME is authoring-house-specific -- observed `ANY!` (Dukes of Hazzard) -and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK` mirror -- and -whose title-key file is NOT always `VTKF000.AACS` (Freedom ships -`VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED from the -parsed UDF tree (`find_hddvd_aacs_dir` + `role_paths`), never hardcoded. - -Each key role (`AacsRole`) resolves to an ordered candidate list -- the BD/UHD -constants first, then whatever the HD DVD directory actually holds -- which -every reader walks with `read_first`, first-that-reads. No reader ever -branches on disc type: a BD/UHD disc has the `/AACS/` files so those win; an -HD DVD has none of them, so it falls through to the discovered entries. -Centralised so `resolve_vid_only`, `read_aacs_inputs`, `read_mkb_content`, and -`read_aacs_version` can never silently diverge the disc_hash / MKB / VID that -another reader feeds a key service. - -`find_hddvd_aacs_dir` identifies the HD DVD AACS directory structurally, NOT by -a hardcoded name: the root child directory whose name ends in `!` (so the -`<name>!_BAK` backup mirror, which also ends in a non-`!` char, is not -mistaken for it) and which contains `MKBROM.AACS`. Observed real names: -`ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD disc has no such -directory -> `None`. - -`role_paths` builds the ordered candidate list: fixed BD/UHD paths first, then -the discovered HD DVD files. For `AacsRole::UnitKey` every `VTKF*.AACS` in the -directory is appended in sorted name order -- a disc may carry more than one -variant (Freedom: `VTKF090` + `VTKF100`), not just `VTKF000`. - -`read_first` walks an AACS role's candidate paths and returns the first that -reads; `read` performs the actual per-path read (full file or bounded prefix), -so callers share the same first-present walk regardless of read style. Returns -`Error::AacsNoKeys` if no candidate is present. Generic over the path element -(`&str` or owned `String`) so it accepts the `Vec<String>` that `role_paths` -builds from the discovered HD DVD directory. - -### Test: `a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory` - -The `!`-suffix and the `MKBROM.AACS` presence test are BOTH required -- the -discovery is a conjunction, not a disjunction. The existing fixtures only ever -present a directory that satisfies both (`AAC!` with `MKBROM.AACS`) alongside -one that satisfies neither (`AAC!_BAK` -- which contains `MKBROM.AACS` but is -ALSO reached only after the real dir), so either half of the conjunction could -be dropped and the same directory would still be found. This test's fixture -has a directory that satisfies the name half and NOT the contents half: it -must not be picked. If it were, the HD DVD path would resolve `MKBROM.AACS`, -`CONTENT_CERT.AACS` and the title-key file under a directory that holds none -of them -- the disc reports "no AACS key files" and never rips. diff --git a/docs/ac3.md b/docs/ac3.md deleted file mode 100644 index 73b7e674..00000000 --- a/docs/ac3.md +++ /dev/null @@ -1,220 +0,0 @@ -# AC3 / E-AC3 parser notes (`src/mux/codec/ac3.rs`) - -## Why a frame set is one sample, never split into separate tracks - -A legacy AC-3 syncframe is a complete access unit on its own, but an E-AC-3 -access unit (ETSI TS 102 366 / ATSC A/52 Annex E) is a whole FRAME SET: the -mandatory independent substream (`substreamid` 0) plus every DEPENDENT -substream frame that follows it, plus any ADDITIONAL independent substreams -(`substreamid` 1..7) and their own dependents. A decoder needs the whole set -to reconstruct the programme (a 5.1 independent substream plus a dependent -substream carrying the extra channels of a 7.1 programme, and the AC-3-core + -E-AC-3-dependent arrangement used for backwards-compatible Dolby Digital -Plus), and every substream of the frame set covers the SAME time period. - -WHY the whole frame set is ONE sample, rather than splitting an additional -independent substream (an associated / commentary service) into a track of -its own: Annex E orders the substreams of a frame set inside a single -elementary stream, all covering one time period, and a decoder handed a frame -set renders the programme it is asked for from it — that is exactly what the -container's E-AC-3 sample and its `dec3`/`num_ind_sub` description denote. -Splitting would mean rewriting the bitstream (re-numbering `substreamid` so -the extracted service becomes substream 0, and rebuilding its frame sets), -because a substream numbered 1..7 with no substream 0 is not a conforming -stream. That is a transcode, not a remux; this parser is lossless, so the -frame set stays whole and player-side programme selection decides what is -heard. - -## `SAMPLE_RATES` / fscod2 - -Sample rates indexed by fscod (0=48kHz, 1=44.1kHz, 2=32kHz). fscod=3 is -reserved in AC-3; in E-AC-3 it signals "fscod2" (reduced rates: 24/22.05/16 -kHz, selected by byte-4 bits `[5:4]`). `frame_sample_rate` decodes fscod2 in -the E-AC-3 case; the table's index-3 entry (48 kHz) is only the fallback when -the header is too short to read fscod2. - -## `MAX_AC3_BUF` derivation - -An AC-3/E-AC-3 syncframe is at most 8192 bytes (the `frame_size > 8192` -reject) and an access unit is a whole frame set: up to 8 independent -substreams, each with up to 8 dependent substreams (ETSI TS 102 366 Annex E) -— 72 syncframes worst case. A cap of one straddling frame set plus slack -therefore has to exceed 72 × 8192 = 576 KiB. If the buffer grows past the cap -without yielding a frame (pathological / never-syncing input) it is dropped -and resync begins, rather than accumulating one PES worth of data per call -for the whole title. - -## `scan_access_units` - -Per ETSI TS 102 366 (ATSC A/52) Annex E an access unit is one FRAME SET: the -mandatory independent substream (`substreamid` 0) plus every DEPENDENT -substream that follows it, plus any ADDITIONAL independent substreams -(`substreamid` 1..7 — associated/commentary services) with their own -dependents. The access unit therefore closes only when the next -`substreamid`-0 independent substream (or, with `at_eos`, the end of the -stream) is seen, and it carries THAT substream's PTS and duration: every -other substream of the frame set describes the SAME time period and adds no -duration of its own. - -`base_pts_ns` times the access unit that begins at `data[0]` — i.e. the -running cadence carried over from the previous call. `anchor` re-anchors the -running PTS to this PES's own timestamp at the first access unit that STARTS -in the newly-appended bytes: a PES timestamp applies to the first access unit -beginning in that PES, never to one that began in an earlier PES and is only -being completed (or was held) here. - -Returns the emitted access units, the offset in `data` from which bytes must -be carried over to the next call, and the PTS to stamp on the access unit -that begins that carry-over. - -## `substream_role` - -Byte 2 of an E-AC-3 syncframe is `strmtyp(2) | substreamid(3) | frmsiz[10:8]` -(ETSI TS 102 366 Annex E BSI). `strmtyp` 0 and 2 are independent substreams -(type 2 being an independent substream that is not the first of the bit -stream); `strmtyp` 1 is the dependent substream. - -Annex E defines a FRAME SET as independent substream 0 — mandatory, always -first — with its dependent substreams, followed by the OPTIONAL additional -independent substreams 1..7, each with their own dependents; all of them -carry the same time period. So the access-unit boundary is an independent -substream with `substreamid` == 0, and NOT merely "an independent substream": -an additional independent substream (an associated or commentary service) -sits INSIDE the frame set already open. Keying the boundary on `strmtyp` -alone made every such substream close the access unit and advance the -running PTS a second time over the same ~32 ms, doubling the timeline (about -a second of A/V drift per second of audio). - -`strmtyp` 3 is reserved: its BSI layout is not defined, so its `substreamid` -bits cannot be trusted and it is treated as starting a fresh access unit — an -unknown frame is never merged into an unrelated programme, and never -discarded as an orphan either. - -Legacy AC-3 (`bsid < 11`) has no substream structure — byte 2 there is the -crc1 field, never `strmtyp` — so it always starts an access unit. - -A frame set MAY carry a substream as several consecutive syncframes of fewer -than six blocks each (Annex E allows numblkscod < 3). Those extra syncframes -are `substreamid` 0 too, so each starts its own access unit here rather than -merging into one frame set. That is deliberate: each carries its OWN -numblkscod-derived duration (see `frame_duration_ns`), so the timeline total -stays exact, and the substreams are still delivered in bitstream order. - -## `frame_crc_ok` - -Per ETSI TS 102 366 (ATSC A/52) the frame carries a CRC-16/ANSI (poly 0x8005, -init 0, non-reflected) over the bytes after the 2-byte syncword — i.e. -`crc16_ansi(&buf[2..]) == 0` covers `frame_size - 2` bytes; the trailing crc -word makes a clean frame's residue zero. A nonzero residue is a -~1-in-65536-certain sign of payload corruption, so the frame is dropped -(silence gap) rather than shipped as a glitch. - -## `acmod_channels` bit layout - -This is the AUTHORITATIVE channel count for the track header: the DVD IFO -`audio_attr_t.channels` nibble is a well-known unreliable/stale field, so the -muxer prefers this over the IFO-claimed count (the bitstream acmod is -authoritative; the IFO audio nibble is not trusted). LFE adds one channel -(e.g. acmod=7 + lfeon → 6 = 5.1). - -Bit layout from the syncword (A/52 §5.3.2 BSI): - -```text - byte 5: bsid(5) | bsmod(3) - byte 6: acmod(3) | [cmixlev(2) if acmod has a centre and acmod!=1] - | [surmixlev(2) if acmod has surround] - | [dsurmod(2) if acmod==2] | lfeon(1) | ... -``` - -`acmod` therefore always occupies byte-6 bits 7-5; `lfeon` follows a variable -number of optional 2-bit fields, so the decoder tracks the bit cursor. - -## `ACMOD_CHANNELS` table - -Base channel count per AC-3 `acmod` (A/52 Table 5.8), BEFORE the LFE. Index is -the 3-bit acmod value; add 1 when `lfeon` is set. - -```text - 0 = 1+1 (Ch1, Ch2) -> 2 4 = 2/1 (L,R,S) -> 3 - 1 = 1/0 (C, mono) -> 1 5 = 3/1 (L,C,R,S) -> 4 - 2 = 2/0 (L, R) -> 2 6 = 2/2 (L,R,SL,SR) -> 4 - 3 = 3/0 (L,C,R) -> 3 7 = 3/2 (L,C,R,SL,SR) -> 5 -``` - -## Test helpers and coverage notes - -### `finalize_ac3_crc` - -Relies on the CRC-16/ANSI residue property: appending `crc16([2..n-2])` -(big-endian) zeroes the register over `[2..n]`. - -### `the_working_buffer_is_reused_across_packets_not_reallocated` - -`parse` runs once per PES packet — of the order of 10^5 times on a feature's -audio track — and used to build a fresh `Vec` every call. The copy itself -cannot go without restructuring the borrow relationship, but the allocation -can: the buffer is taken out of `self`, filled, and put back with its -capacity intact. Asserting on capacity is deliberately white-box, because -that is exactly what regresses if someone reverts to `to_vec()` — the -behaviour would be identical and no other test would notice. - -### `make_eac3_frame` byte layout - -ETSI TS 102 366 Annex E: byte 2 is strmtyp(2) | substreamid(3) | frmsiz[10:8], -byte 3 is frmsiz[7:0], and the frame is `(frmsiz + 1) * 2` bytes. byte 4 = -fscod 0 (48 kHz), numblkscod 3 (6 blocks → 1536 samples → 32 ms), acmod 7 + -lfeon (5.1); byte 5 = bsid 16 so the E-AC-3 paths are taken. - -### `a_held_access_unit_is_not_rescanned_from_its_first_frame_every_packet` - -An access unit closes only at the next `substreamid`-0 independent substream, -so one that keeps gaining dependent substreams stays OPEN across PES -boundaries and its bytes stay in the carry-over. The carry-over must not be -re-scanned — and re-CRCed — from the access unit's first byte on every -packet: the buffer only stops growing at `MAX_AC3_BUF` (1 MiB), and a PES on -a DVD is about 2 KiB, so re-deriving the held access unit costs work -quadratic in the packets fed. - -Measured directly, because a work bound has no frame-level shadow: -`frames_scanned` counts the syncframes the scanner sizes and CRC-gates. -Re-scanning from byte 0 examines 1 + 2 + ... + (N+1) frames. - -Mutation: pass `None` for `held` in `parse` (or drop the `if let Some(h) = -held` resume) — the count returns to the quadratic figure. - -### `a_discontinuity_drops_the_held_access_unit_with_its_bytes` - -`parse` clears `self.acc` on a discontinuity because the buffered bytes are a -truncated frame. The held access unit is described by OFFSETS into exactly -those bytes, so it has to go with them. Without `self.held = None`, the next -packet resumes a HeldAu whose `start`/`end` were computed against the -pre-gap buffer but are applied to the unrelated post-gap bytes — splicing -audio across the gap at best, and indexing past the end of the new, shorter -buffer at worst. - -The two existing discontinuity tests use plain AC-3 (bsid < 11), which never -holds an access unit open, so neither of them reaches this reset. - -### `a_held_access_unit_is_dropped_when_the_track_poisons_before_it_resumes` - -Mutation this catches: deleting (or inverting) the resume-path re-check `if -drop_reason.is_none() && self.tally.is_poisoned()` at the top of -`scan_access_units`. The verdict on a held access unit is frozen at the -moment it was OPENED, and `ac3_drop_reason` reads the tally BEFORE the -previous access unit is closed — so the very drop that crosses the poison -threshold lands after the next unit's verdict was already taken as `None`. -Without the re-check that unit is emitted as an ordinary frame after the -track has been judged too damaged to mux: corrupt audio passed through as -success, and worse, it is the ONE frame that escapes a whole-track fallback -whose entire point is that nothing after the verdict ships. - -Neither existing held-AU test reaches this: both keep a pristine tally. - -The fixture drives the exact interleaving above. `DropTally` poisons once -`verified_dropped * 2 > kept + dropped` past the 200-AU gate, so one PES -carries 200 CRC-failing `substreamid`-0 syncframes followed by a clean one. -Closing corrupt unit #200 (which happens only when the clean frame is -reached) latches the poison — after that clean frame's own verdict was -computed. Being an E-AC-3 unit that can still gain substreams, it is then -HELD across the boundary with `drop_reason: None`, which is precisely the -state the re-check exists for. diff --git a/docs/adts.md b/docs/adts.md deleted file mode 100644 index cc99ee2b..00000000 --- a/docs/adts.md +++ /dev/null @@ -1,17 +0,0 @@ -# AAC ADTS framing and validation - -ADTS syncframes are buffered across PES boundaries and split when one PES -contains multiple frames. Each output is raw AAC: the ADTS header and optional -CRC are removed, and AudioSpecificConfig is exposed for the container header. -A PES timestamp anchors its first frame; subsequent frames advance by the -header's sample count and sample rate. The first byte's provenance is retained. - -Reserved sample rates and lengths shorter than the header are rejected. CRC -contents are not verified. Before ADTS sync is seen, nonsync payloads retain -the raw-AAC passthrough behavior; after sync, nonsync PES payloads are treated -as continuations. A partial EOF frame is not emitted. Discontinuities discard -a partial frame and restart timestamp anchoring. Buffering is capped at 1 MiB. - -Rust tests exercise every split point, multiple frames per PES, CRC removal, -AudioSpecificConfig, discontinuities, timestamps and malformed headers. The -FFmpeg QA gate compares decoded PCM after deliberately fragmented input. diff --git a/docs/api-design.md b/docs/api-design.md deleted file mode 100644 index d5351926..00000000 --- a/docs/api-design.md +++ /dev/null @@ -1,237 +0,0 @@ -# libfreemkv API Design - -## Principles - -1. Lib provides building blocks. App composes them. -2. No English text in lib. Error codes only. App handles i18n. -3. No display logic in lib. App decides what to show. -4. Streams are the pipeline. Each stage wraps the next. -5. Lib fires events. App listens. - -## Core API - -```rust -// Open drive — explicit steps, app prints between them -let mut drive = Drive::open(path)?; -drive.wait_ready()?; -drive.init()?; -drive.probe_disc()?; - -// Scan disc -let disc = Disc::scan(&mut drive, &ScanOptions::default())?; - -// Browse -disc.titles // Vec<DiscTitle> -disc.format // BD / UHD / DVD -disc.capacity_gb() -``` - -## PES Pipeline (primary API) - -The PES pipeline is the main way to move content. All streams produce/consume -PES frames. The pipeline just reads frames and writes frames. - -```rust -// URL-based — any source to any destination -let opts = InputOptions::default(); -let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?; -let title = input.info().clone(); -let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?; - -while let Ok(Some(frame)) = input.read() { - output.write(&frame)?; -} -output.finish()?; -``` - -The `FrameSource` and `FrameSink` traits — direction is type-checked, so -calling `read()` on a write-only sink (or `write()` on a read-only source) -is a compile error rather than a runtime fault: - -```rust -pub trait FrameSource: Send { - fn read(&mut self) -> Result<Option<PesFrame>, Error>; - fn info(&self) -> &DiscTitle; - fn codec_private(&self, track: usize) -> Option<Vec<u8>> { None } - fn headers_ready(&self) -> bool { true } -} - -pub trait FrameSink: Send { - fn write(&mut self, frame: &PesFrame) -> Result<(), Error>; - fn finish(self: Box<Self>) -> Result<(), Error>; - fn info(&self) -> &DiscTitle; -} -``` - -## Streams - -All streams implement `FrameSource` (read) and/or `FrameSink` (write); the -directional split prevents runtime "wrong-direction" errors. URL-based -resolvers open any stream by string. - -| Stream | Input | Output | URL | Transport | -|--------|-------|--------|-----|-----------| -| DiscStream | Yes | -- | `disc://` `disc:///dev/sg4` | Optical drive via SCSI | -| IsoStream | Yes | Yes | `iso://path.iso` | Blu-ray ISO image | -| MkvStream | Yes | Yes | `mkv://path` | Matroska container | -| M2tsStream | Yes | Yes | `m2ts://path` | BD-TS with FMKV metadata header | -| NetworkStream | Yes (listen) | Yes (connect) | `network://host:port` | TCP with FMKV metadata header | -| StdioStream | Yes (stdin) | Yes (stdout) | `stdio://` | Raw byte pipe | -| NullStream | -- | Yes | `null://` | Discard sink (byte counter) | - -All URLs require a `scheme://path` format. Bare paths are rejected. - -```rust -// PES pipeline (frame-level) — input() returns Box<dyn FrameSource>, -// output() returns Box<dyn FrameSink>. -let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream -let input = libfreemkv::input("iso://Movie.iso", &opts)?; // IsoStream -let output = libfreemkv::output("mkv://Movie.mkv", &title)?; // MkvOutputStream -let output = libfreemkv::output("m2ts://Movie.m2ts", &title)?; // M2tsOutputStream -let output = libfreemkv::output("network://192.0.2.10:9000", &title)?; // NetworkOutputStream -let output = libfreemkv::output("null://", &title)?; // NullOutputStream -``` - -### FMKV Metadata Header - -M2tsStream and NetworkStream embed a JSON metadata header before the BD-TS data: - -``` -[8B magic "FMKV\0\0\0\0"][4B JSON length][JSON metadata][padding to 192B boundary][BD-TS data...] -``` - -The header carries title name, duration, codec_privates, and full stream layout -(PIDs, codecs, languages, labels). This allows the receiving end to set up -demuxing and track metadata without scanning the TS. - -## Events - -Lib fires events during operations. App provides a callback. No display, no text. - -```rust -pub struct Event { - pub kind: EventKind, -} - -pub enum EventKind { - // Init / scan - DriveOpened { device: String }, - DriveReady, - InitComplete { success: bool }, - ProbeComplete { success: bool }, - ScanComplete { titles: usize }, - - // Read pipeline - BytesRead { bytes: u64, total: u64 }, - ReadError { sector: u64, error: Error }, - SpeedChange { speed_kbs: u16 }, - ExtentStart { index: usize, start_sector: u64, sector_count: u64 }, - SectorSkipped { sector: u64 }, - BatchSizeChanged { new_size: u16, reason: BatchSizeReason }, - Complete { bytes: u64, errors: u32 }, - - // Kept for forward-compat; not emitted in 0.13.6+ - Retry { attempt: u32 }, - SectorRecovered { sector: u64 }, -} -``` - -Emission notes: - -- `BytesRead { bytes, total }` is emitted from `DiscStream::fill_extents` - after each successful sector read. `bytes` is the cumulative running - total; `total` is the precomputed extent sum (0 if unknown). -- `SpeedChange` is emitted from the public `Drive::set_speed` API path. - It is no longer emitted from a recovery hot loop (recovery loop removed - in 0.13.6). -- `BatchSizeChanged` fires from the `DiscStream` adaptive sizer on shrink - (read failed at a larger size) and on probe-up (clean-read streak hit - the threshold). Consumers use it to display a "recovering" state - distinct from "ripping normally". -- `Retry` and `SectorRecovered` are NOT emitted in 0.13.6+. They were - tied to the inline `Drive::read` recovery phases that were removed; the - variants are kept for forward compatibility so consumers' match arms - don't need conditional compilation. - -Events report what happened. App decides what to do. GUI shows a dialog. CLI -prints a line. Server logs to file. - -## File Layout - -``` -libfreemkv/src/ -├── lib.rs Public exports -├── error.rs Error codes (no English) -├── event.rs Event types for callbacks -├── halt.rs Halt cancellation token (Arc<AtomicBool> wrapper) -├── io/ Pipeline + WritebackFile primitives -│ ├── mod.rs Re-exports WritebackFile, Pipeline, Sink, Flow -│ ├── pipeline.rs Generic Pipeline<I, R> + Sink trait -│ ├── writeback_file.rs WritebackFile (was crate::io::Writer) -│ └── writeback.rs sync_file_range pipeline -├── drive/ Drive (open, init, single-shot read) -│ ├── mod.rs Drive struct, init, read (single-shot), eject -│ ├── capture.rs Raw drive SCSI capture (INQUIRY/GET_CONFIG) for contribution -│ ├── linux.rs Linux drive discovery -│ ├── macos.rs macOS drive discovery -│ └── windows.rs Windows drive discovery -├── disc/ Disc (scan, titles, AACS setup, per-format parsing) -│ ├── mod.rs Disc struct, scan, titles, formats -│ ├── bluray.rs Blu-ray / UHD scanning (MPLS/CLPI-driven) -│ ├── dvd.rs DVD-Video scanning (IFO-driven) -│ ├── hddvd.rs HD-DVD scanning -│ ├── extract.rs Per-extent content extraction -│ ├── encrypt.rs Encrypted-range mapping for content reads -│ ├── dvd_audio_probe.rs DVD audio-stream probing -│ └── pgs_forced_probe.rs PGS forced-subtitle probing -├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI) -├── unlock.rs Unlocker trait + registry (pluggable unlock seam) -├── aacs/ AACS decryption (handshake, keys, keydb, decrypt) -├── css/ DVD CSS cipher -├── decrypt.rs Unified decrypt dispatcher (AACS/CSS/None) -├── pes.rs PES frame types, FrameSource / FrameSink traits -├── sector/ Sector I/O -│ ├── mod.rs SectorSource, SectorSink traits -│ ├── file.rs FileSectorSource, FileSectorSink (ISO-backed) -│ └── decrypting.rs DecryptingSectorSource decorator -├── udf.rs UDF 2.50 filesystem parser -├── mpls.rs MPLS playlist parser -├── clpi.rs CLPI clip info parser -├── ifo.rs DVD IFO parser -├── labels/ BD-J label extraction (5 format parsers) -├── keydb.rs KEYDB download, parse, save -├── identity.rs DriveId from INQUIRY -├── speed.rs DriveSpeed enum -├── mux/ -│ ├── mod.rs Public mux exports -│ ├── resolve.rs URL parser + input/output (Box<dyn FrameSource/Sink>) -│ ├── meta.rs FMKV header format -│ ├── disc.rs DiscStream (optical drive → PES) -│ ├── iso.rs IsoStream (ISO image read) -│ ├── isowriter.rs ISO image writer (UDF, AVDP, multi-extent) -│ ├── mkvstream.rs MkvStream (bidirectional Matroska) -│ ├── mkvout.rs MkvOutputStream (PES → MKV) -│ ├── m2ts.rs M2tsStream (BD-TS) -│ ├── pesout.rs PES output streams (M2ts, Network, Stdio, Null) -│ ├── network.rs NetworkStream (TCP + FMKV header) -│ ├── stdio.rs StdioStream (stdin/stdout pipe) -│ ├── null.rs NullStream (discard + byte counter) -│ ├── lookahead.rs LookaheadBuffer (codec header scanning) -│ ├── ts.rs BD-TS demuxer + PAT/PMT scanner -│ ├── tsreader.rs TS reader utilities -│ ├── tsmux.rs TS muxer (PES → BD-TS packets) -│ ├── ps.rs MPEG-2 PS demuxer (DVD) -│ ├── ebml.rs EBML read/write primitives -│ ├── mkv.rs MKV muxer (tracks, clusters, cues) -│ └── codec/ Frame parsers (H.264, HEVC, MPEG-2, VC-1, AC3, EAC3, DTS, TrueHD, LPCM, PGS) -└── ... - -freemkv/src/ -├── main.rs CLI dispatcher (URL routing) -├── pipe.rs PES pipeline — source → dest copy -├── disc_info.rs Disc/file info display -├── info.rs Drive info + profile submission -├── strings.rs i18n string table -├── output.rs Verbosity-filtered output -└── build.rs Bundled locale code generation -``` diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index f43d7201..00000000 --- a/docs/architecture.md +++ /dev/null @@ -1,222 +0,0 @@ -# libfreemkv Architecture - -Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD. -Rust library with profiles bundled and all SCSI communication handled in-process. -AACS decryption requires an external `keydb.cfg` (default -`~/.config/freemkv/keydb.cfg`) — the derivation math is internal, but no AACS key -material is compiled in; DVD CSS player keys are the only compiled-in keys. - -**Repository:** <https://github.com/freemkv/libfreemkv> -**License:** MIT - ---- - -## Design Principles - -1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and - format handling live in the library. CLI binaries are thin wrappers that call - `Drive::open()` and `Disc::scan()`. - -2. **Firmware-clean core.** libfreemkv ships no firmware, no unlock CDBs, and no - drive profiles. Drive-unlock logic is plugged in by an external crate through - the `Unlocker` trait + registry (`register_unlocker`); without one the library - still rips via the host-certificate AACS handshake. - -3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are - available. Callers read cleartext sectors without knowing whether the disc - was encrypted. - -4. **Structured errors, no English.** Every error has a numeric code (E1000-E8000). - The library never formats user-facing messages -- applications do that. - -5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a - policy level. If a profile exists, the library uses it. - -6. **Streams are dumb pipes.** Streams read/write PES frames. They don't know - about encryption, transport format, or source type. Decrypt is a stream-internal - concern; the pipeline just moves frames. - ---- - -## Module Map - -``` -libfreemkv (lib.rs) -│ -├── Drive Access -│ ├── drive Drive — open, identify, init, single-shot read -│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI) -│ ├── unlock Unlocker trait + registry — the pluggable unlock seam -│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C -│ ├── speed DriveSpeed enum, SET CD SPEED CDB builder -│ └── event Event system for drive status callbacks -│ -├── Disc Scanning -│ ├── disc Disc::scan() — titles, streams, extents, AACS setup -│ ├── udf UDF 2.50 filesystem reader (metadata partitions) -│ ├── mpls MPLS playlist parser — clips, streams, STN table -│ ├── clpi CLPI clip info parser — EP map, sector extents -│ ├── ifo DVD IFO parser — title sets, PGC chains, cell addresses -│ └── labels/ BD-J label extraction (5 formats: Paramount, Criterion, Pixelogic, CTRM, Deluxe) -│ -├── Encryption -│ ├── aacs/ AACS handshake, KEYDB, VUK lookup, MKB, unit decryption -│ ├── css DVD CSS cipher — table-driven, no external keys needed -│ └── decrypt decrypt_sectors() — unified AACS/CSS/None dispatcher -│ -├── Streaming -│ ├── mux/ Stream implementations (Disc, ISO, MKV, M2TS, Network, Stdio, Null) -│ ├── pes PES frame types; the unified pes::Stream (PesStream) read/write trait -│ └── sector/ SectorSource / SectorSink traits, FileSector{Source,Sink}, DecryptingSectorSource -│ -├── I/O Primitives -│ ├── halt Halt cancellation token (one Arc<AtomicBool>, cloneable) -│ └── io/ Pipeline<I, R> + Sink trait + WritebackFile (bounded-cache writer) -│ -├── Support -│ ├── keydb KEYDB.cfg download, parse, verify, save -│ └── error Error enum with numeric codes E1000-E8000 -│ -└── lib.rs Public API re-exports -``` - ---- - -## Drive Access Flow - -``` -Drive::open(Path::new("/dev/sg4")) - │ - ├─ scsi::open() Open /dev/sg4 (async write/poll/read) - ├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C - └─ Drive ready for init/read -``` - -After open: -- `init()` -- routes to the matching registered unlocker (if any); otherwise - a no-op and the cert handshake carries the disc -- `probe_disc()` -- probe disc surface for optimal speeds -- `read(lba, count, buf, recovery)` -- single-shot read; `recovery` only selects the per-CDB timeout (`READ_TIMEOUT_MS` 10 s vs. `READ_RECOVERY_TIMEOUT_MS` 60 s) -- `wait_ready()` -- wait for disc insertion -- `eject()` -- eject tray - -Recovery is layered above `Drive::read`, not inside it. Layer 1 -(`freemkv_engine::recovery::patch`, in the engine crate) handles bad-range -retry by replaying the ddrescue mapfile. -Layer 3 (`DiscStream::fill_extents` adaptive batch sizer) handles in-loop -request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry) -was removed in 0.13.6 — see [`rip-recovery.md`](rip-recovery.md) and -the stop-wedge postmortem (2026-04-25). - ---- - -## Disc Scanning Flow - -``` -Disc::scan(&mut drive, &ScanOptions) - │ - ├─ READ CAPACITY Get disc size in sectors - ├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root) - ├─ For each BDMV/PLAYLIST/*.mpls: - │ ├─ mpls::parse() Extract play items, STN streams - │ └─ For each clip: - │ └─ clpi::parse() EP map → sector extents for the clip's time range - ├─ labels::detect() Parse BD-J JARs for stream labels - ├─ Detect AACS Check for /AACS directory on disc - └─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted) -``` - -For DVD: -``` -Disc::scan_dvd(&mut drive, &ScanOptions) - │ - ├─ ifo::parse() Parse VIDEO_TS.IFO — title sets, PGC chains - ├─ CSS detection Check disc structure flag - └─ CSS key cracking Table-driven, no KEYDB needed -``` - -The result is a `Disc` with: -- `titles: Vec<DiscTitle>` -- sorted by duration, each with streams, sector extents, codec_privates -- `decrypt_keys()` -- DecryptKeys for content decryption -- `encrypted: bool` -- whether the disc uses AACS/CSS - ---- - -## AACS Decryption - -Four key resolution paths, tried in order: - -| Path | Method | Speed | -|------|--------|-------| -| 1 | VUK lookup by disc hash in KEYDB.cfg | Instant | -| 2 | Media Key + Volume ID from KEYDB → derive VUK | Fast | -| 3 | Processing Keys + MKB → Media Key → VUK | Medium | -| 4 | Device Keys + MKB subset-difference tree → VUK | Slow | - -The AACS handshake (`aacs/handshake`) performs ECDH key agreement over the -AACS 1.0 160-bit elliptic curve to obtain: -- **Volume ID** -- needed for VUK derivation (paths 2-4) -- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption - -Content decryption uses AES-128-CBC on 6144-byte aligned units. The -`ContentReader` handles this transparently. Streams that read sectors -(DiscStream, IsoStream) decrypt internally — the pipeline sees clean bytes. - ---- - -## Error Codes - -All errors carry a numeric code for programmatic handling. No user-facing text -is baked into the library. - -| Range | Category | Examples | -|-------|----------|----------| -| E1xxx | Device errors | `DeviceNotFound`, `DevicePermission` | -| E2xxx | Profile errors | `UnsupportedDrive`, `ProfileNotFound`, `ProfileParse` | -| E3xxx | Unlock errors | `UnlockFailed`, `SignatureMismatch`, `NotUnlocked`, `NotCalibrated` | -| E4xxx | SCSI errors | `ScsiError`, `ScsiTimeout` | -| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) | -| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) | -| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) | -| E8xxx | KEYDB errors | `KeydbError` (download, parse, save) | - ---- - -## Platform Support - -| Platform | Transport | Status | -|----------|-----------|--------| -| Linux | async sg write/poll/read on `/dev/sg*` | Supported | -| macOS | IOKit SCSITask | Supported | -| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported | - -The `ScsiTransport` trait abstracts the platform. Adding a new platform requires -implementing `execute()` for that OS and wiring it into `scsi::open()`. - ---- - -## Drive Unlock - -libfreemkv carries no drive-unlock mechanism. The `Unlocker` trait + registry -(`src/unlock.rs`) is the seam: an external crate implements `Unlocker` and -registers it once via `register_unlocker(...)`. At drive-prep the registry is -walked in order and the first unlocker whose `matches()` is true is asked to -`unlock_drive()` over the raw `ScsiTransport`. If none match, the drive is left -untouched and the host-certificate AACS handshake carries the disc. - -The implementor owns everything firmware-specific — drive profiles, vendor CDBs, -variant logic. Concrete unlockers live in the separate -**[freemkv-unlock](https://github.com/freemkv/freemkv-unlock)** repository, never -in libfreemkv. See [`drive-access.md`](drive-access.md#drive-unlock-seam) for the -trait definition and routing. - ---- - -## Build - -``` -cargo build --release -``` - -Produces a Rust library crate. The `libc` dependency is unix-only (gated). -All three platforms build and pass CI. diff --git a/docs/au-assembly.md b/docs/au-assembly.md deleted file mode 100644 index 6d66aad4..00000000 --- a/docs/au-assembly.md +++ /dev/null @@ -1,217 +0,0 @@ -# `src/mux/au_assembly.rs` — access-unit assembly - -## Why this exists - -The contract a codec parser converts is `PES → access units (Frames)`. A -*transport* stream hands the parser one AU per PES for free (BD aligns one -access unit per PES; the TS demuxer reassembles to the -`payload_unit_start_indicator`). A *program* stream does not — the PS muxer -chops the elementary stream into fixed-size PES fragments with no AU -alignment, and only the first fragment of an AU carries a PTS. So a parser -that assumes one-AU-per-PES (h264/hevc/vc1, written against TS) mis-frames a -program stream, while `mpeg2` — the DVD/PS codec — must reassemble across PES. - -`AuAssembler` is that reassembly, factored out so EVERY program-stream video -parser shares one implementation instead of hand-rolling the buffer. The -h264/hevc/vc1 parsers (`Mode::StartCode` / `Mode::Vc1`) and the MPEG-2 parser -(`Mode::Mpeg2`, via `AuAssembler::mpeg2`) all drive it. It buffers PES-fragment -bytes and emits one AU per codec AU boundary, carrying the AU-start -timing/source forward. Since the boundary is a codec start code, it lives with -the codec parser (which picks the marker); only the generic buffering + -timing-carry is shared here. - -This is *inside* the parser, not a pipeline stage: the pipeline stays -`Demuxer → PES → Parser → Frames`, and the demuxer stays codec-agnostic. Every -stream a parser sees runs through one of these — self-framing codecs (MPEG-2, -audio) use `Mode::Passthrough` so the parser code path is uniform. - -## `MAX_AU_BUFFER` - -Safety cap on a single in-progress access unit. A real coded picture is far -below this; a stream that never yields a second AU boundary is force-flushed -at the cap rather than buffering without bound on hostile/corrupt input. - -## `MAX_MARKS` - -Cap on buffered timing/discontinuity marks. A real access unit spans a few -hundred PES fragments at most; this bounds the mark deques so a run of -zero-length (or start-code-free) timed fragments — which grow no buffer bytes -and so never trip the `MAX_AU_BUFFER` mark-prune — cannot accumulate marks -without bound on hostile/corrupt disc input. - -## `AuAssembler::for_codec` - -Video codecs whose parsers assume AU-complete PES (H.264 / HEVC / VC-1) get a -`Mode::StartCode` assembler; MPEG-2 (self-reassembles) and audio/subtitle -codecs (self-framing) get `Mode::Passthrough` so callers can run every stream -through this uniformly. - -## `AuAssembler::mpeg2` - -The MPEG-2 parser owns one of these directly (rather than hand-rolling the -buffer): the demux layer runs MPEG-2 through `Mode::Passthrough` and hands -each fragment to the parser, which feeds them here to be reframed on picture -boundaries. - -## `AuAssembler::push`/`push_owned` - -For a self-framing (`Passthrough`) stream the payload is MOVED straight into -the emitted unit with no copy — the common DVD/HD-DVD case (MPEG-2 video, all -audio). A buffering mode copies into `buf` exactly as `push`. - -## `take_front` - -Detaches `buf[..end]` as the emitted AU's own `Vec` and leaves `buf` holding -the tail. - -The AU's bytes are HANDED OVER — `buf`'s allocation becomes the returned `Vec` -and a fresh buffer (pre-sized to the same capacity, so the next AU accumulates -without re-growing) takes its place holding only the short tail. -`buf[..end].to_vec()` + `drain(..end)` instead copied every AU out in full: on -a UHD HEVC title that is a whole-frame memcpy (hundreds of KB) per coded -picture, ~200k times, for bytes that are about to be discarded from `buf` -anyway. - -The allocation COUNT is unchanged (one per AU either way — the frame `Vec` -before, the replacement buffer now), so the only difference is the copy that -no longer happens. Nothing depends on `buf` keeping its identity: the only -state tied to `buf[0]`'s position is `base`/`scan_pos`/`opener_pos`, which the -caller updates immediately after. - -Falls back to a copy when the buffer's capacity is far larger than the AU (a -small AU after a multi-MB one): handing over would otherwise attach an -oversized idle allocation to a small frame for as long as the frame queues -downstream, trading a copy for resident memory. - -That fallback must not become permanent. `buf`'s capacity used to be a one-way -high-water mark — the replacement buffer was created with `cap.max(tail_len)`, -and the copy path's `drain` also preserves `cap` — so once ONE large AU had -been assembled, every later smaller AU satisfied `cap > 2*end` and took the -copy path forever. On a UHD HEVC title the first IDR grows `buf` to ~4-8 MB, -after which each ~200-400 KB P/B AU paid a whole-AU allocation plus a -whole-AU memcpy plus a tail memmove for ~99% of the ~200,000 coded pictures — -tens of GB of exactly the memcpy this handover exists to remove. So the copy -path now also RELEASES the high-water capacity, which re-arms the handover -for the next AU: one copy after a size step down, not one per frame forever. - -## `drop_marks_before` - -This is the STREAM-START case: bytes ahead of the first AU boundary are the -tail of an access unit that began before we had sync, and there is no prior -AU for them to be discontinuous *from*. Carrying a mark forward here would arm -the resync gate at the head of every title and drop its first GOP. - -## `discard_gap_before` - -This is the BACKSTOP case: `MAX_AU_BUFFER` bytes accumulated with no AU start -code in them, so the run is unusable and gets thrown away. Unlike the -stream-start trim above, there IS a prior AU here, and whatever follows -definitively does not continue it — a decoder handed the next picture would -resolve its references against frames separated from it by megabytes of -discarded data. - -So the discard is itself a discontinuity, whether or not the source signalled -one. It is recorded as a sticky flag rather than an offset mark because a -mark placed at the new base would be retired moments later by the pre-sync -trim that follows resync — the gap has to outlive the bytes that caused it. -It arms the resync gate, which drops to the next keyframe instead of emitting -a picture with dangling references. - -Timing marks before `off` are still retired — they describe bytes that no -longer exist, and the AU that eventually emits takes its PTS from the -fragment that actually opened it. - -## Tests - -### `a_backstop_discard_marks_the_next_au_discontinuous` - -The 8 MiB backstop throws away a start-code-free run as unusable. The AU that -eventually emits after that discard MUST be marked discontinuous, whether or -not the source ever signalled a discontinuity: megabytes of the stream are -simply gone, so the next picture cannot resolve its references against the -last one that was emitted. - -`discontinuity` is what arms the resync gate downstream (`resync.rs`, driven -from `mux/disc.rs`), which drops to the next keyframe rather than emitting a -picture with dangling references. If the flag is retired with the discarded -bytes, the gate never arms and the broken picture goes out — a silent -corruption, which is the one class of loss this crate refuses to have. - -### `a_stream_start_trim_does_not_mark_the_first_au_discontinuous` - -The opposite case, and the reason the two call sites are separate. Bytes -ahead of the FIRST access-unit delimiter are the tail of an AU that began -before we had sync. There is no prior AU for them to be discontinuous from, so -retiring the marks there is right — and necessary: marking the first AU of -every title discontinuous would arm the resync gate at the head of each one -and drop its opening GOP. - -### `a_source_signalled_discontinuity_reaches_the_au_it_opens` - -A source-signalled discontinuity reaches the AU it opens. This is the -`disc_marks` path — the ORIGINAL mechanism, distinct from the sticky -`pending_gap` the backstop sets. Nothing else pins it: the two tests above -drive `pending_gap`, and a mark placed on a fragment that is later discarded -is retired by design. - -Deliberately NOT combined with the backstop. A previous version of this test -signalled the discontinuity on the first over-cap push and asserted the flag -on the AU after the discard — but that first run still has the next AU's -delimiter at `buf[0]`, so it force-flushes as an over-long AU, and THAT AU -consumes the mark. The assertion was then satisfied entirely by -`pending_gap`, making the test a duplicate of the one above it under a name -promising something else. The two mechanisms cannot be isolated in one -fixture, so they get one test each. - -### `drained_au_takes_over_the_buffer_allocation_without_copying` - -MEASURED: a drained AU must be HANDED the accumulation buffer's allocation, -not copied out of it. The emitted `Vec`'s data pointer is the buffer's own -pointer — which is only true if no full-frame copy happened. -(`buf[..end].to_vec()` allocates fresh, so the pointers differ.) One -whole-AU memcpy per coded picture is ~200k memcpys of a few hundred KB each -on a UHD feature. - -### `handover_survives_a_large_au_instead_of_copying_every_later_one` - -MEASURED: `take_front`'s copy fallback must not become permanent. - -`buf`'s capacity used to be a one-way high-water mark, and the copy path's -`drain` preserves it, so after ONE large AU every later smaller AU satisfied -`cap > 2*end` and copied forever. On a UHD HEVC title the first IDR grows -`buf` to multiple MB, after which ~99% of the ~200,000 coded pictures each -paid a whole-AU allocation + whole-AU memcpy + tail memmove — tens of GB of -exactly the copy the handover exists to remove. Counted at the copy path -itself: one copy is expected right after the size step down; a per-frame -copy is the bug. - -### `discarded_pre_sync_marks_do_not_time_the_first_access_unit` - -After the pre-sync bytes are discarded, the emitted AU must take the timing -of the fragment that ACTUALLY opened it. `drop_marks_before` is what retires -the discarded fragment's marks; a no-op there stamps the first real access -unit with the PTS and source of bytes that were thrown away — a whole-title -A/V sync offset, since every later frame is timed relative to it. - -### AU-opener detection: the per-mode start-code rule - -`au_opener_from` duplicates a rule each codec parser also encodes; the tests -below pin it to the normative byte values so a drift between copies shows up. - -### `au_opener_from_locates_the_real_start_code_per_codec` - -The opener offset must be the position of the real start code, never a fixed -0. A constant `Some(0)` makes every pre-sync run of junk bytes look like the -head of an access unit, so the first AU of every stream that does not begin -exactly on a start code is emitted with junk glued to its front. - -### `for_codec_routes_each_video_codec_to_its_reassembly_mode` - -`for_codec` is the dispatch that decides whether a stream is REASSEMBLED -across PES fragments or passed straight through. Getting it wrong is silent: -an H.264/HEVC/VC-1 stream routed to `Passthrough` on a program source emits -one "frame" per PES fragment — a few hundred bytes of a coded picture, framed -as a whole access unit — and the output plays as corruption, not as an error. - -Each mode is identified BEHAVIOURALLY (feed a two-AU stream in two halves and -see whether it reassembles), so the case cannot pass by matching a constant. diff --git a/docs/bdmt.md b/docs/bdmt.md deleted file mode 100644 index 9ebd57a2..00000000 --- a/docs/bdmt.md +++ /dev/null @@ -1,42 +0,0 @@ -# BDMV disc-library metadata (`bdmt.rs`) - -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. - -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. - -## `MAX_BDMT_BYTES` - -The size comes from attacker-controlled UDF metadata; real files are a few -KB, so 1 MiB is generous while preventing a crafted huge-size entry from -triggering an oversized allocation in `read_file`. - -## `parse_bdmt_xml` - -Title-element preference: `<di:name>` → `<di:title>` → -`<di:tableOfContents>/<di:titleName>` (first match wins, per the -authoring-tool conventions documented above). - -## `looks_like_xml` - -Rejects candidate description strings that are themselves XML fragments — -observed on a captured disc, 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. diff --git a/docs/bdnav.md b/docs/bdnav.md deleted file mode 100644 index 80f44fc3..00000000 --- a/docs/bdnav.md +++ /dev/null @@ -1,21 +0,0 @@ -# bdnav — HDMV navigation resolver - -Notes relocated from `src/bdnav/mod.rs` doc comments (kept out of the source -to respect the comment-guard's prose caps). - -## No per-disc special-casing - -There is no per-disc special-casing: one correctly-booted VM resolves -densely-branched dispatchers as well as simple ones. The honest boundary is -BD-J (the feature is chosen by a Java Xlet, not by the HDMV VM); there — and -on any malformed data or non-convergence — the resolver ABSTAINS (`None`) -and selection falls back to the structural/heuristic order. - -## `resolve_feature` - -Resolves the playlist id the disc's First-Play navigation plays as the -feature, restricted to ids the caller marks as feature candidates -(`is_feature_candidate` — typically video-bearing, non-trivial titles, so a -short logo/pre-roll `PlayPlayList` is skipped). Returns `None` for BD-J -discs, missing/malformed nav data, or when navigation does not converge on -a candidate. diff --git a/docs/bluray.md b/docs/bluray.md deleted file mode 100644 index 878793da..00000000 --- a/docs/bluray.md +++ /dev/null @@ -1,239 +0,0 @@ -# src/disc/bluray.rs — extended notes - -Overflow prose for comments that were trimmed to the internal 3-line cap in -`src/disc/bluray.rs`. Each section is pointed to by a `// See docs/bluray.md -— <topic>` comment at the corresponding site. - -## CLIP_STREAM_EXTS - -Stream-file extensions probed for a BD-family playlist clip, in priority -order. A clip is normally `.m2ts`; AACS 2.1 (FMTS) discs name the main -feature `.fmts` (an M2TS transport stream plus forensic variant segments) -and 3D discs use `.ssif`. `.m2ts` is tried first, so a normal clip is -unaffected — the fallback only runs when `.m2ts` is absent (exactly when -`file_extents` errors). - -Scope: these are all variants that live in `BDMV/STREAM/` and are reached -through an MPLS playlist. HD-DVD's `.evo` does NOT belong here — HD-DVD is -a different tree (`HVDVD_TS/`) with `.XPL` playlists and needs its own -enumerator (a peer to `parse_playlist`), not another extension in this -list. - -## scan_bluray_titles - -Cancellation: `halt` is polled between playlists and once more after the -loop, and a read that fails with `Error::Halted` — how a live drive -reports a Stop, since `Drive::checked_exec` fails every command once its -flag is set and `Drive::read` preserves the variant — is propagated rather -than swallowed. `Halted` is the only error this returns from the -enumeration itself; an unreadable or unparseable playlist keeps its -best-effort skip. - -It has to be an error and not a short title list, for the same reason -spelled out on `Disc::scan_hddvd_titles`: a cancelled enumeration that -returned `Ok` would be indistinguishable from a disc that genuinely holds -fewer titles, and the caller would cache and act on it. Before this, a Stop -pressed mid-scan failed every remaining `.mpls` read in turn, each one -silently skipped, and the scan returned `Ok(truncated)` at rc=0. - -## parse_playlist - -Sums PlayItem durations; returns `Ok(None)` if the playlist is under 30 -seconds (skips menu / clip-info stub playlists), fails to parse, or names a -clip that cannot be resolved. Physical sector extents are pulled from the -UDF allocation descriptors of each referenced `.m2ts` (deduplicated by -clip_id). - -`Ok(None)` keeps its two benign meanings (unparseable MPLS, sub-30s -playlist) plus the deliberate "drop this title" outcomes below. `Err` means -the scan is over: today that is only `Error::Halted`, the operator's Stop, -which must not be reported as a disc that simply holds fewer titles. - -## Test: parse_playlist_keeps_exactly_30_seconds - -At exactly 30s the playlist is kept (`< 30.0` is strict). The fixture is a -fully wired BDMV (`make_bdmv_fs`), not the bare `make_min_fs` this used to -use. `make_min_fs` lays no STREAM/CLIPINF, so the play item's `.clpi` read -now fails and the title drops — which is correct behaviour for an -unresolvable clip but has nothing to do with the 30-second boundary this -test exists to pin. Wiring the clip keeps the test measuring exactly one -thing. - -## make_bdmv_fs_ssif - -Full BDMV with a real Blu-ray 3D layout: `.ssif` files under -`BDMV/STREAM/SSIF/<clip>.ssif` (note the SSIF subdirectory, unlike -`make_bdmv_fs_ext`) plus a matching `.clpi` in CLIPINF. Resolving the SSIF -is what latches `is_3d = true` in `parse_playlist`. - -## Test: parse_playlist_fmts_clip_resolves_extent - -AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The -`CLIP_STREAM_EXTS` fallback in `parse_playlist` must still resolve the -physical extent — before the fix the hard-coded `.m2ts` path errored, -yielding empty extents (a silent empty rip and 0 encrypted samples for key -resolution). Size still comes from the `.clpi`, which parses regardless. - -## Test: parse_playlist_dedups_repeated_clip_extents_and_size - -THE 0.31.0 DEDUP PATH. A playlist that references the SAME clip_id from -multiple PlayItems (seamless split / looped segment) must count the -physical extents and packet bytes exactly once — mux reads extents in -order, so a duplicate would mux the A/V twice and inflate `size_bytes` -(bluray.rs: `first_ref = seen_clips.insert(...)` gates both `total_size +=` -and the `extents.push`). Per-PlayItem Clip entries are still recorded for -both. - -## Test: parse_playlist_missing_clpi_yields_no_title - -A clip whose `.clpi` is missing must yield no title. - -This test used to assert the opposite — a title with `size_bytes == 0` and -empty extents — and its docstring quoted the buggy control flow ("bluray.rs -only fetches extents inside the `if let Ok(clpi_data)`") as if it were the -specification. It was blessing the defect: the title's `duration_secs` is -summed from the PlayItems before the clip is resolved, so the returned -title advertised the movie's full runtime while carrying none of its -bytes, and the discarded error meant not one log line said so. That is the -flagship failure class of this crate — a failure that looks like success — -reached through an ordinary missing or scratched CLIPINF file. - -The correct behaviour is the same as for a clip whose extents cannot be -resolved (see `parse_playlist_unreadable_clip_icb_yields_no_title`): drop -the title and log the read's own error code. - -That last clause is now asserted, not merely asked for. The site's comment -insists on `e.code()` because a missing `.clpi` (E6003), a scratched one -(E6000) and a malformed one (E6002) are different populations, but nothing -checked it: putting a literal back compiled and passed. Mutation: `"E6017"` -(or any fixed code) in place of `"E{}", e.code()` fails here, and so does -dropping the warn entirely — a silent drop is the same invisible title -loss this test was written for, one step later. - -## Test: parse_playlist_unrecorded_extent_yields_no_title - -A clip stream whose ICB declares an UNRECORDED (ECMA-167 4/14.14.1.1 -type-1) extent must not yield a title at all. - -The extent is allocated to the file but was never written, so the file's -content there is zeros while the media holds whatever was left at those -sectors. Neither answer a `(lba, sector_count)` read plan can give is true -— reading it splices undefined sectors into the rip as content, dropping -it slides every later extent's byte space — so the title is refused rather -than mis-ripped. This fixture is the shape a crafted disc uses to get such -a range into a title's extent list. - -(The `sectors > 0 && lba > 0` filter below the resolver stays as defence in -depth; a zero-length AD is only reachable as an unrecorded descriptor, -since a zero-length TYPE 0 one terminates the AD list.) - -## Test: parse_playlist_unreadable_clip_icb_yields_no_title - -An unrecorded extent was never the only way a clip fails to resolve. - -RED BEFORE GREEN: this fixture gives the .m2ts an ICB whose descriptor tag -is neither 261 nor 266, so `file_extents` returns `DiscRead` — the same -variant a scratched sector under a real clip's ICB produces, which is the -ordinary way this happens on real media. Before the fix only -`UdfUnrecordedExtent` set the drop flag, so this fell through to the "file -absent" path and `parse_playlist` returned a title: full declared duration -from the play item, `total_size` already counted from the .clpi, and zero -extents — a movie advertising its runtime with the content missing, and -not one log line. The clip must drop the title exactly as an unrecorded -extent does. - -## Test: parse_playlist_logs_a_non_absence_ssif_failure_the_m2ts_fallback_hid - -A non-absence SSIF failure that the `.m2ts` fallback then papers over must -be logged, with its own code. - -`unresolved` had exactly one reader — `if let (None, Some(code))` — so a -code recorded for `/BDMV/STREAM/SSIF/<clip>.ssif` was thrown away whenever -the fallback succeeded. The disc here IS a 3D disc: the SSIF is present and -carries both eyes, and only its ICB is unreadable (a scratched sector, the -ordinary way this happens). The title shipped base-view 2D at rc=0 with -`is_3d` false and not one log line, so the operator's rip is silently -missing the dependent view and the journal cannot tell this disc apart -from one that was only ever 2D. - -The title is deliberately still returned — the base view resolved, so -refusing would trade a degraded rip for no rip. The defect being fixed is -the silence, not the fallback. - -Mutations this catches: deleting the new `(Some(_), Some(code))` arm, or -restoring the `if let (None, Some(code))` shape, leaves no event to find; -logging a fixed code instead of `e.code()` fails the code assertion; -making the arm `return Ok(None)` fails the title assertion. - -## Test: parse_playlist_id_falls_back_to_zero_when_suffix_is_not_mpls - -A filename that is long enough (>= 5 bytes) but does NOT end in ".mpls" -must NOT have its last 5 bytes stripped — the whole string is handed to -the numeric parse instead, which fails and falls back to playlist_id 0 -(bluray.rs `filename.len() >= 5 && filename[len-5..].eq_ignore_ascii_case(".mpls")`). - -## Test: scan_bluray_titles_skips_non_mpls_extension_file - -A non-directory PLAYLIST entry whose name does NOT end in ".mpls" must be -skipped even though its content parses as a perfectly good (long) MPLS -playlist — extension gating, not content sniffing, decides eligibility -(bluray.rs `!entry.is_dir && entry.name...ends_with(".mpls")`). - -## HaltingReader - -A `SectorSource` that fails every read in `halt_range` with -`Error::Halted` — exactly how a live drive behaves once the operator -presses Stop: `Drive::checked_exec` fails every SCSI command with `Halted` -from then on, and `Drive::read` deliberately preserves the variant. Reads -outside the range still succeed, so a test can aim the cancel at one -structure and leave the scan far enough along to have something to -truncate. - -## Test: halted_playlist_read_is_not_reported_as_a_shorter_disc - -A Stop on a live drive never touches `ScanOptions::halt`: `Drive` has its -own flag and `checked_exec` fails every SCSI command with `Error::Halted` -once it is set. The Blu-ray enumerator must not swallow that into a -successful scan. - -RED BEFORE GREEN: with the propagation reverted this returned -`Ok([00800])` — the `if let Ok(mpls_data)` skipped the cancelled read of -00801.mpls, the loop ended, and a half-enumerated disc came back at -success. One title from a two-title disc is indistinguishable from a disc -that genuinely holds one title, and the caller caches and rips from it. - -The halt lands on the second playlist deliberately: it is the last -iteration, so nothing after it would poll a flag — only propagating the -read's own error catches it. - -## Test: halted_clpi_read_is_not_accounted_as_an_unresolvable_clip - -The same cancel landing on a `.clpi` read must not be classified as an -unresolvable clip either. - -RED BEFORE GREEN: with the `Err(Error::Halted)` arm removed from the -CLIPINF match, the cancel fell into the generic "clip could not be -resolved" arm, which logs a disc-defect code and drops the title — -accounting an operator Stop as a scratched disc, and (in the scan loop) -dropping every remaining playlist in turn for a truncated `Ok`. With the -fix the cancel is propagated with its own variant intact. - -## Test: halted_extent_resolve_is_not_a_title_missing_its_clip - -And the same cancel landing on `file_extents` — the clip's ICB, not its -CLIPINF — must propagate too. - -RED BEFORE GREEN: `note` used to exempt `Halted` from the unresolved -classification (correctly — a cancel is not an authoring hole) but had no -way to propagate it, so the resolver simply produced no extents and -`parse_playlist` returned a title with the clip's runtime counted, its -`size_bytes` counted from the .clpi, and zero bytes behind it. Measured -with the propagation reverted: `Ok(Some((768000, [])))` — the flagship -defect shape, wearing a cancel. - -## Test: read_meta_title_ignores_non_xml_file_regardless_of_content - -A non-.xml file must be ignored even if its content looks like a valid -meta XML (contains a `<di:name>`) — extension gating, not content -sniffing, decides eligibility (bluray.rs `!e.is_dir && -e.name...ends_with(".xml")`). diff --git a/docs/bounded.md b/docs/bounded.md deleted file mode 100644 index 9065c4ff..00000000 --- a/docs/bounded.md +++ /dev/null @@ -1,61 +0,0 @@ -# `src/io/bounded.rs` — bounded-syscall primitive - -## 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. - -## `bounded_syscall` contract details - -- `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). -- 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. diff --git a/docs/byte-prefetcher.md b/docs/byte-prefetcher.md deleted file mode 100644 index 8827daa5..00000000 --- a/docs/byte-prefetcher.md +++ /dev/null @@ -1,111 +0,0 @@ -# `byte_prefetcher` — design notes and test rationale - -`BytePrefetcher` (`src/io/byte_prefetcher.rs`) is the `std::io::Read` -analogue of `crate::sector::PrefetchedSectorSource`. It spawns a -producer thread that fills a bounded pool of `Vec<u8>` chunks from the -underlying reader and ships them through a channel; the consumer pulls -filled chunks, uses them, and sends the empty `Vec<u8>` back through a -recycle channel so the producer can re-fill in place. Result: zero -allocations and zero cross-thread frees in the steady-state hot loop. - -This is the byte-stream half of the freemkv mux highway — -`BytePrefetcher` feeds `crate::mux::demux_thread::DemuxThread` for -`m2ts://` (the only in-tree caller today, via `crate::mux::resolve`), -and works for any stream whose source is an `io::Read` rather than a -`SectorSource`. - -## `PrefetchShell` - -Drop blocks the calling thread until the producer exits. To guarantee -a prompt exit, drop the forward receiver and the recycle sender first -so the producer observes channel disconnection (or cancel the `Halt` -passed to `BytePrefetcher::new`, which the producer polls at -`POLL_INTERVAL` granularity even while parked on a channel op). - -## Test coverage rationale - -- **`recycle_depth_is_forward_depth_plus_one`** — `RECYCLE_DEPTH` must - be one MORE than `FORWARD_DEPTH` per its own doc comment: the - producer needs at least one buffer to fill while the consumer holds - the other `FORWARD_DEPTH`-worth in flight. A `+` -> `*`/`-` mutation - on `FORWARD_DEPTH + 1` would under-size the recycle channel (e.g. - `FORWARD_DEPTH * 1 == FORWARD_DEPTH`, one short), which starves the - producer of a spare buffer. - -- **`into_channels_drop_releases_producer`** — the CRITICAL - regression: after `into_channels`, dropping the returned forward - receiver + recycle sender must let the producer observe - disconnection and exit, so dropping the `PrefetchShell` (which - joins the producer) returns promptly. With the old clone+forget the - leaked endpoints kept the producer blocked and this join hung - forever. - -- **`delivers_all_bytes_in_order_across_chunks`** — CORE CONTRACT: the - prefetcher must deliver every source byte, in order, exactly once — - never silently truncate or duplicate. Source is 5000 bytes; chunk - size 1024 forces multiple chunks (4 full + 1 short of 904). The - reassembled stream must equal the source. Mutation: replacing - `buf.truncate(n)` with a no-op would over-report bytes on the final - short read and this fails. - -- **`short_read_truncates_to_actual_length`** — a reader that returns - fewer bytes than requested per call must NOT leave stale tail bytes - in the delivered chunk. Cursor over 10 bytes with a 4096 chunk - yields a single 10-byte chunk; the consumer must see exactly 10 - bytes, not 4096. Grounds `buf.truncate(n)`. Mutation: delete the - truncate and the chunk would carry 4086 zero bytes of padding, - failing the length assert. - -- **`empty_source_yields_clean_eof_no_batches`** — EOF semantics: an - empty source (Cursor over `[]`) yields `read() == Ok(0)` on the - first call, which the producer treats as EOF and returns, dropping - tx. The consumer sees RecvError (zero batches), NOT an Err batch and - NOT a zero-length Ok batch. Grounds the `Ok(0) => return` arm. - Mutation: changing `Ok(0) => return` to `Ok(0) => continue` would - spin forever (test times out). - -- **`read_error_is_propagated_as_err_batch`** — a reader that fails - mid-stream must surface the `io::Error` as an `Err` batch on the - forward channel, not swallow it. We deliver one good chunk then an - error. The consumer must see the good bytes followed by the error. - Mutation: changing `let _ = tx.send(Err(e)); return;` to a plain - `return` would drop the error silently and this fails. - -- **`read_panic_surfaces_as_err_batch_not_clean_eof`** — PANIC - propagation: a reader that PANICS mid-stream must NOT be read as a - clean EOF at the demux boundary. The producer's `catch_unwind` sends - an explicit `Err` sentinel before the thread unwinds, so the - consumer sees the good bytes followed by an error batch — never a - silent truncation. Without the `catch_unwind` the panic would just - drop `tx`, the consumer would see RecvError (== clean EOF) and the - partial output would be finalized as if complete. - -- **`recycled_buffer_carries_no_stale_tail`** — recycle-buffer reuse - must NOT leak stale bytes between chunks of different lengths. After - a full chunk, a short read reuses the same recycled buffer; the - regrow-then-truncate sequence must yield only fresh bytes. Verified - by reassembling the full stream. Source: 8 bytes of 0xAA + 3 bytes - of 0xBB, with chunk_bytes=8 → chunk0 = 8×0xAA, chunk1 = 3×0xBB. - -- **`exact_multiple_length_no_trailing_empty_batch`** — exact-multiple - boundary: when the source length is an exact multiple of - chunk_bytes, the final non-empty chunk is followed by an `Ok(0)` EOF - read, NOT a spurious empty Ok batch. 12 bytes with chunk_bytes=4 → - three 4-byte chunks then clean EOF. Total bytes must equal 12 and no - zero-length batch may appear. - -- **`drop_finite_prefetcher_joins_cleanly`** — dropping the - `BytePrefetcher` directly (without `into_channels`) must join the - producer cleanly when the source is finite. The producer reaches - EOF, drops tx, and exits; Drop's join returns. Grounds the - `BytePrefetcher` `Drop` impl. Mutation: removing the `Ok(0) => - return` EOF exit would hang this join. - -- **`drop_endless_prefetcher_joins_cleanly`** — regression: dropping a - `BytePrefetcher` directly (without `into_channels`) with an ENDLESS - source must not deadlock. Before the fix, `Drop` joined the producer - while `rx`/`recycle_tx` were still alive (sibling field drop order), - so the producer filled the depth-2 forward channel and then spun in - `send_timeout` forever (rx never drained, halt=None). The fix drops - `rx`+`recycle_tx` BEFORE the join so the producer sees - `SendTimeoutError::Disconnected` and exits. diff --git a/docs/class-reader.md b/docs/class-reader.md deleted file mode 100644 index 6723fb9f..00000000 --- a/docs/class-reader.md +++ /dev/null @@ -1,48 +0,0 @@ -# `labels::class_reader` - -Hand-rolled JVM `.class` file reader, tailored to the subset needed for BD-J -label extraction (Deluxe / dbp / similar frameworks). Implements JVMS §4 -(class file format) and §6 (bytecode) only as far as: constant pool, -methods, the `Code` attribute, and a non-allocating bytecode iterator. - -No external deps beyond `std`. No `unsafe`. No panics on malformed input — -every parse fault is a typed `Error`. This is shared infrastructure for any -label parser that needs structured access to `.class` files inside a -`/BDMV/JAR/<x>.jar`. - -## `ConstantPool::from_entries` - -Test-only constructor — builds a constant pool directly from a vector of -entries. Real callers go through `ClassFile::parse`, which builds this from -class-file bytes. Used by parser unit tests (e.g. `labels::deluxe`) that -need to exercise bytecode walkers against synthetic class fixtures without -hand-rolling valid `.class` byte buffers. - -Caller is responsible for: prepending a `CpInfo::Empty` at index 0 (the -spec-reserved slot), and inserting a `CpInfo::Empty` after each Long/Double -entry (the 2-slot quirk). - -## `decode_modified_utf8` - -Decodes JVM "modified UTF-8" (JVMS §4.4.7). Practically identical to -standard UTF-8 for the BMP-printable subset seen in label strings, but with -two notable deviations: - -- U+0000 is encoded as the two-byte sequence `0xC0 0x80`, not as `0x00`. -- Supplementary characters (U+10000..) are encoded as a UTF-16 surrogate - pair, each surrogate emitted as 3-byte modified UTF-8. - -For label data (mostly ASCII / Latin-1 / CJK in BMP), the simple -implementation here covers everything we'll encounter. The `0xC0 0x80` → -U+0000 case is tolerated explicitly; supplementary characters would need -surrogate-pair stitching, but no label-relevant string uses them. - -## `Reader::slice` overflow test - -`Reader::slice` takes an attacker-supplied length: a JVMS `u4` -`attribute_length` / `code_length` (§4.7, §4.7.3) or a `u2` Utf8 length -(§4.4.7). Adding it to `pos` without a wrap check panics on overflow in -debug and, in release, wraps to a small end offset that slips past the -bounds check and then panics inside the slice index. Both are panics -escaping a parser whose whole input is untrusted disc bytes; the contract -is an EOF error. diff --git a/docs/clpi-audit.md b/docs/clpi-audit.md deleted file mode 100644 index f02b450e..00000000 --- a/docs/clpi-audit.md +++ /dev/null @@ -1,18 +0,0 @@ -# CLPI vs MPLS cross-validation diagnostic - -`src/labels/clpi_audit.rs` walks both per-clip CLPI program info and -per-playlist MPLS STN tables, normalizes their stream lists by `(PID, -language, coding_type)`, and classifies each PID into one of four -buckets: - -1. **CLPI only** — a stream present in a `.clpi` ProgramInfo that no - playlist STN table references (orphan on disc). -2. **MPLS only** — a stream a playlist references that no `.clpi` - ProgramInfo lists (indicates a parser disagreement). -3. **Match** — both sources agree on coding_type and language. -4. **Divergent** — both sources see the PID but disagree on - coding_type or language. - -`audit` returns a structured `ClpiVsMplsAudit` report. This is a -diagnostic surface only; it does not feed the label-selection -pipeline. diff --git a/docs/clpi.md b/docs/clpi.md deleted file mode 100644 index 0b4195f4..00000000 --- a/docs/clpi.md +++ /dev/null @@ -1,243 +0,0 @@ -# CLPI Clip Information Format - -## What is CLPI? - -CLPI (Clip Information) files describe the structure of individual M2TS transport streams on a Blu-ray disc. Each `.clpi` file in `BDMV/CLIPINF/` corresponds to one `.m2ts` file in `BDMV/STREAM/` with the same numeric name (e.g. `00001.clpi` describes `00001.m2ts`). - -The most important data in a CLPI file is the **EP (Entry Point) map**, which provides timestamp-to-sector mapping. This is essential for seeking and for extracting the specific sector ranges that correspond to a playlist's in/out time window. - -## File Structure - -CLPI files use big-endian byte order. The header: - -``` -Offset Size Field ------- ---- ----- -0 4 Magic: "HDMV" -4 4 Version: "0200" (BD) or "0300" (UHD BD) -8 4 SequenceInfo start offset -12 4 ProgramInfo start offset -16 4 CPI (Characteristic Point Information) start offset -20 4 ClipMark start offset -24 4 ExtensionData start offset -``` - -### ClipInfo Section (offset 40) - -Contains basic clip metadata. The source packet count (total number of 192-byte source packets in the M2TS file) is at offset 56: - -``` -Offset Size Field ------- ---- ----- -40 4 ClipInfo length -44 2 Reserved -46 1 Stream type -47 1 Application type -48 4 Reserved -52 4 TS recording rate -56 4 Source packet count -``` - -The source packet count multiplied by 192 gives the total byte size of the M2TS file. - -## EP Map - -The EP map lives inside the CPI section and provides random access into the transport stream. It maps PTS timestamps to Source Packet Numbers (SPN), enabling precise seeking without scanning the stream. - -### CPI Section Layout - -``` -Offset Size Field ------- ---- ----- -0 4 CPI length -4 2 Reserved / CPI type -6 ... EP map -``` - -### EP Map Header - -``` -Offset Size Field ------- ---- ----- -0 1 Reserved -1 1 Number of stream PID entries -2 ... Stream PID entry headers (one per stream) -``` - -Each stream PID entry header (14 bytes): - -``` -Offset Size Field ------- ---- ----- -2 2 stream_PID (byte-aligned) -4 10 Bit-packed block, 80 bits total (see below) -``` - -The stream PID entry is **not** byte-aligned past `stream_PID`. Bytes 4..14 are one -80-bit packed field, read as a `u64` plus a trailing `u16`: - -Bits Width Field ----- ----- ----- -0-9 10 reserved -10-13 4 EP_stream_type -14-29 16 num_EP_coarse -30-47 18 num_EP_fine -48-79 32 EP_map_start_address (relative to the EP map start) - -Note `num_EP_fine` is **18 bits**, not 32. See `parse_cpi` in `src/clpi.rs`. - -libfreemkv parses only the first stream (primary video), which is sufficient for sector-level seeking. - -### Two-Level Index - -The EP map uses a two-level structure to compress what would otherwise be a very large lookup table: - -- **Coarse entries**: low-resolution index covering large time/sector ranges -- **Fine entries**: high-resolution entries within each coarse range - -Each coarse entry points to a range of fine entries via `ref_to_fine_id`. - -### Coarse Entries (8 bytes each) - -Located immediately after the fine table start offset (4 bytes) in the per-stream EP map: - -``` -Bits Field ------- ----- -[31:14] ref_to_fine_id (18 bits) -- index of first fine entry in this range -[13:0] pts_coarse (14 bits) -- upper bits of PTS -``` - -Second dword: - -``` -Bits Field ------- ----- -[31:0] spn_coarse (32 bits) -- upper bits of SPN -``` - -### Fine Entries (4 bytes each) - -Located at the fine table start offset within the per-stream EP map: - -``` -Bits Field ------- ----- -[31] is_angle_change_point (1 bit) -[30:28] I_end_position_offset (3 bits) -[27:17] pts_fine (11 bits) -- lower bits of PTS -[16:0] spn_fine (17 bits) -- lower bits of SPN -``` - -## Reconstructing Full PTS and SPN - -The full timestamp and packet number are assembled by combining the coarse and fine components: - -### Full PTS - -``` -full_pts = (pts_coarse << 19) + (pts_fine << 8) -``` - -The coarse component provides the upper bits, shifted left by 19. The fine component provides mid-range bits, shifted left by 8. The resulting PTS is in 45kHz ticks, matching the MPLS timestamp format. - -### Full SPN - -``` -full_spn = (spn_coarse & 0xFFFE0000) + spn_fine -``` - -The coarse SPN provides the upper 15 bits (masked to clear the lower 17). The fine SPN provides the lower 17 bits. The result is a Source Packet Number -- each source packet is 192 bytes in the M2TS file. - -### Resolved EP Map - -The `resolved_ep_map()` method iterates through all coarse entries and their associated fine entries to produce a flat list of `(PTS, SPN)` pairs. For each coarse entry at index `ci`: - -- Fine entries range from `coarse[ci].ref_to_fine_id` to `coarse[ci+1].ref_to_fine_id` (or end of fine table for the last coarse entry). -- Each fine entry is combined with its parent coarse entry to produce one full `(PTS, SPN)` pair. - -## Deriving Sector Extents for Ripping - -Given a playlist's in_time and out_time (from MPLS), the CLPI EP map provides the sector ranges to read from disc. The `get_extents()` method does this in three steps: - -### Step 1: PTS to SPN - -Binary search the resolved EP map for the in_time and out_time: - -- **start_spn**: the SPN at or before in_time (seek backward to the nearest I-frame) -- **end_spn**: the SPN at or after out_time (include the full GOP) - -### Step 2: SPN to Byte Offset - -Each Source Packet is 192 bytes (188 bytes MPEG-TS payload + 4 bytes M2TS header): - -``` -byte_offset = spn * 192 -``` - -### Step 3: Byte Offset to Sector - -M2TS files are stored contiguously on disc. Sectors are 2048 bytes: - -``` -start_sector = start_byte / 2048 -end_sector = (end_byte + 2047) / 2048 -sector_count = end_sector - start_sector -``` - -The resulting `Extent` contains `start_lba` (relative to the M2TS file's starting sector on disc) and `sector_count`. The caller adds the file's absolute starting LBA from UDF to get disc-absolute sector numbers. - -### Alignment Note - -The 192-byte source packet size and 2048-byte sector size share no common factor beyond 1. A single sector contains roughly 10.67 source packets. The conversion rounds start down and end up to ensure complete coverage. - -## Putting It Together - -The full ripping pipeline chains three parsers: - -1. **MPLS** provides clip IDs and in/out timestamps. -2. **CLPI** converts those timestamps to SPN ranges, then to sector extents. -3. **UDF** provides the file's starting LBA on disc for absolute sector addressing. - -The `Disc::scan()` method in `src/disc/mod.rs` orchestrates this: for each play item in each playlist, it loads the corresponding CLPI, calls `get_extents()` with the play item's in/out times, and collects the resulting sector ranges into the title's extent list. - -## ProgramInfo Section (Per-Stream Metadata) - -`parse_program_info` in `src/clpi.rs` reads per-stream (pid, coding_type, -language, codec sub-fields) from the ProgramInfo section, per the Blu-ray -Disc Read-Only Format Part 3 CLIPINF (CLPI) specification: - -```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 in parse_program_info) -``` - -It returns `Vec::new()` on any structural mismatch — errors aren't -propagated because the EP map is the primary CLPI output, and a corrupt -program_info shouldn't break sector-range lookups. - -### Test fixture notes - -- `prog_info_start_zero_does_not_parse_header_as_program_info`: the - fixture is crafted so that parsing from offset 0 WOULD yield a stream - (num_programs at `[5]`, a second program header whose num_streams - byte at `[20]` is 1, then a well-formed stream record), so an empty - result can only come from the `prog_info_start > 0` gate in `parse`. - -## References - -- BD-ROM Part 3, Section 5.5: Clip Information file format -- https://github.com/lw/BluRay/wiki/CLPI diff --git a/docs/codec-mod.md b/docs/codec-mod.md deleted file mode 100644 index 2fb07fda..00000000 --- a/docs/codec-mod.md +++ /dev/null @@ -1,79 +0,0 @@ -# `src/mux/codec/mod.rs` — extended notes - -Relocated prose from doc comments in `mod.rs`, trimmed there to stay within -the comment-guard's prose caps. Each `//`/`///` pointer in the source names -the section here it corresponds to. - -## `Frame::discontinuity` - -Carried per-FRAME (not per-PES) because buffering parsers decouple the frame -from the PES that carried the gap signal: MPEG-2 emits whole GOPs, H.264/HEVC -lag one access unit behind the PES that produced them. A clean rip leaves -every frame `false`; the flag is only ever set on the degraded/conceal path -(P3/B1), when a frame's data begins after packets the demuxer never received -— an undecryptable unit concealed as NULL-TS upstream, or a continuity break -in a damaged source. - -## Drop-on-undecodable policy across codecs ("clean muxes always") - -- **Audio with independent access units** (DTS, AC-3/E-AC-3, …) gates each AU - through a per-codec corruption check and drops the ones that fail, keeping - A/V sync (a drop is a silence gap, never a shift) and logging every drop - via the shared `dropgate::DropTally`. DTS validates via its core-frame - header (ETSI TS 102 114); AC-3 uses its native frame CRC. -- **LPCM is excluded on purpose**: raw PCM carries no framing or integrity - data, so a corrupt sample is indistinguishable from a quiet one — there is - nothing to detect, so nothing can be honestly dropped. -- **Video is excluded on purpose**: H.264/HEVC/MPEG-2/VC-1 are inter-frame - predicted, so dropping one frame corrupts every frame that references it - until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the - ResyncGate) and lets the decoder conceal — a fundamentally different model - than per-frame audio dropping. -- TrueHD/MLP, FLAC, MP2/MP3 and AAC-ADTS also gate undecodable frames via a - `DropTally` (poison/drop-forward for MLP's inter-AU restart state on a - major-sync boundary; CRC/sync-verdict drops for the passthrough codecs). - -## `codec_private()` — why "absent" is the only truthful answer for some parsers - -A codec parser's `codec_private()` feeds `DiscStream::codec_private`, which -the MKV muxer turns directly into the track's `CodecPrivate` element (RFC -9559 §5.1.4.1.24): `Some(bytes)` writes an element holding exactly those -bytes, `None` omits the element entirely. The two are NOT interchangeable — -a zero-length `CodecPrivate` asserts that the codec's initialisation data IS -empty, which is not true of any codec, and a one-byte one asserts a config no -decoder can parse. - -The gating parsers (ADTS, MPEG audio, FLAC) and the passthrough parser -extract no configuration at all: they validate and forward frames whose -configuration is carried in band (ADTS headers, MPEG-1 audio frame headers, -FLAC frame headers) or supplied by the source container. Having derived -nothing, the only truthful answer they can give is "absent" — any `Some` -would be a value they invented. Nothing else in the test suite distinguished -the two, so each of these impls could have returned a fabricated `Some` and -produced a malformed track header unnoticed. - -## `provenance_guard` test module - -Every emitted frame must carry the source byte offset of the packet it came -from. That invariant held only for video for as long as it existed: `dts`, -`ac3`, `adts`, `truehd`, `pgs`, `dvdsub`, `flac`, `lpcm`, `mpegaudio` and the -passthrough parser all built frames with `source: None`, so a multi-clip -title could not place audio or subtitles by byte and fell back to inferring -the clip from timestamps — which is what made branched titles run minutes -long. - -Nothing asserted it, so nothing caught it. Finding it took a brace-balanced -scan of the tree by hand, which also turned up five sites a regex had -missed. `provenance_guard` is that scan, as a test. - -### `code_only` / `frame_literals` design - -`frame_literals` walks `Frame { .. }` literals with balanced braces. A regex -cannot do this — `Frame` blocks contain nested braces (`coding: Some(..)`, -closures) and a non-greedy match stops at the first `}`, which is how five -sites survived the first pass. - -`code_only` strips line comments before scanning. A comment that MENTIONS -`source: None` is prose, not a construction — this guard's own doc comment -tripped it on the first run, which is the same mistake as grepping a file -and matching its commentary. diff --git a/docs/coding.md b/docs/coding.md deleted file mode 100644 index d232e8dd..00000000 --- a/docs/coding.md +++ /dev/null @@ -1,17 +0,0 @@ -# `src/mux/codec/coding.rs` - -## Spec references - -ITU-T H.273 (CICP code points, shared elsewhere), ISO/IEC 13818-2 §6.3.10 -(MPEG-2 picture coding extension: `top_field_first`, `repeat_first_field`, -`progressive_frame`), RFC 9559 §5.1.4.1.28 (Matroska `FieldOrder` element -0x9D). - -## HDR10 unit scaling - -`Hdr10Metadata` values are stored in their raw SEI integer units (not yet -scaled to the Matroska float domain); the muxer applies the H.265 → Matroska -unit conversion at emit time so the scaling lives in exactly one place. - -Spec: Rec. ITU-T H.265 D.2.28 (Mastering Display Colour Volume, payloadType -137) and D.2.35 (Content Light Level Info, payloadType 144). diff --git a/docs/consts.md b/docs/consts.md deleted file mode 100644 index 540bdde0..00000000 --- a/docs/consts.md +++ /dev/null @@ -1,11 +0,0 @@ -# `consts::coding_type` — spec citations - -`coding_type` is one registry used in two places that share the same value -space: the MPEG-TS PMT `stream_type` (ISO/IEC 13818-1 Table 2-34) and the -Blu-ray STN/CLPI `stream_coding_type` (BD-ROM Part 3). - -The standardized video codes (`0x02`, `0x1B`, `0x24`) are ISO assignments -(ISO/IEC 13818-1 Table 2-34); `0xEA` (VC-1) is a BD-ROM convention in the ISO -user-private range. The `0x80..=0xA2` audio/graphics codes also sit in the -user-private range and follow the Blu-ray Disc Association / ATSC A/52 -convention. diff --git a/docs/crc.md b/docs/crc.md deleted file mode 100644 index 63a4fb3b..00000000 --- a/docs/crc.md +++ /dev/null @@ -1,14 +0,0 @@ -# CRC helpers (`src/mux/codec/crc.rs`) - -## `crc16_mlp` reversed byte order - -MLP's checksum is the "reversed" scheme. `crc16_mlp` emits its two bytes in -the OPPOSITE order to a standard little-endian CRC readout, so the caller -swaps them back and compares against the stored trailer word read -LITTLE-endian — see `truehd::mlp_major_sync_crc_ok`, which is authoritative. - -Comparing big-endian instead is precisely the bug that function was fixed -for: it could never validate a real extended major sync, so whole TrueHD -tracks were dropped silently. This comment used to prescribe exactly that, -and to point at a `truehd::mlp_major_sync_ok` that does not exist. -Verified against real MLP/TrueHD bitstreams (225/225 major-sync AUs). diff --git a/docs/criterion.md b/docs/criterion.md deleted file mode 100644 index 98e347c4..00000000 --- a/docs/criterion.md +++ /dev/null @@ -1,87 +0,0 @@ -# `src/labels/criterion.rs` — internal notes - -## `assign_stream_numbers` - -Assign a 1-based stream number per `StreamInfo`, parallel to `infos`. - -A stream mapped in `playbackconfig.xml` (`stream_map`) keeps its -mapped number. Streams with no mapping (absent or incomplete -`playbackconfig.xml`, or an unmatched `StreamInfo_ID`) are numbered -1-based per type — but the fallback counter SKIPS any number already -claimed via the map, so a synthesized number can never collide with a -map-assigned one. (Both numbering domains are 1-based per type, and -`apply_labels` matches on `(type, stream_number)`, so a collision -would mislabel tracks.) - -Returns `None` when the 1-based stream-number space is exhausted — -every number in `1..=u16::MAX` for that type is either already -claimed by the map or already synthesized. That is unreachable on -real media: the BD STN_table carries at most 32 primary audio and 32 -PG streams per playlist, so the 65535-wide space leaves >2000x -headroom. It IS reachable from a crafted `streamproperties.xml` -listing >65535 stream entries, and the only correct answers there are -"fail the parse" or "emit colliding numbers"; we fail. - -The skip search is bounded by the numbering space itself: a `u16` -`saturating_add` here parked the counter at `u16::MAX` forever -whenever the map also claimed `u16::MAX`, turning an overflow guard -into a hang that `apply()`'s `catch_unwind` cannot interrupt. The -counters are therefore widened to `u32` so the skip loop strictly -increases toward a fixed ceiling (guaranteeing termination) and -exhaustion is reported rather than absorbed. - -## Test: immunity pin, section-boundary half - -Covers `an_unterminated_stream_element_shortens_the_list_it_cannot_extend_it`. - -`parse_stream_infos` emits one `StreamInfo` per `*StreamInfos` element -unconditionally — no filter, no `continue` — so an element whose -fields are missing or unrecognized still occupies its position, and -`assign_stream_numbers` still spends a number on it. - -That is the property that keeps this parser out of the failure mode -where a skipped entry pulls every later label one stream forward. It -is load-bearing for the fallback path specifically: with no -`playbackconfig.xml` the numbers come purely from position in this -list, so dropping an element there would shift the rest. - -Mutation: skip elements with an empty `ID`/`LangInfoID` → the two -real audio streams renumber to 1 and 2. - -Each stream here is one closed XML element, and every field is read -out of `&text[start..end]` — the range `xml::find_element` returned — -so one element can never absorb the next one's fields, however the -document is malformed around it. Contrast the flat-string walk in -pixelogic, where a section whose end marker is missing keeps -consuming entries as STN slots. - -The missing-boundary case fails closed. An element with no close tag -of its own ends at the NEXT close tag, so it absorbs the element -behind it — the list comes back SHORTER. It cannot come back longer: -nothing outside a returned range is ever read as a stream, and -`find_element` yields `None` rather than a range running to EOF when -no close tag exists at all. A malformed document can cost this parser -a slot; it can never invent one. - -Mutation: read fields from the document rather than the element's -range, or let a close-less element run to EOF → the trailing elements -re-enter the list as extra streams. - -## Test: `exhausted_numbering_terminates_instead_of_looping` - -A crafted `streamproperties.xml` can drive the fallback counter to -the top of the 1-based u16 stream-number space and then present one -more unmapped stream whose successor number is also claimed by the -map. - -This must TERMINATE. The bound is the numbering space itself, so the -assertion is on the spec-derived exhaustion behaviour (`None`), not -on any tunable constant. Run on a worker thread with a deadline so a -non-terminating loop fails the test in 20 s instead of hanging CI. - -## Test: `full_u16_numbering_space_is_usable_and_unique` - -The whole 1-based u16 space must remain usable: 65535 unmapped audio -streams get 65535 distinct numbers with no panic and no wrap. The -literals here are the JVMS-independent, spec-derived size of a u16 -1-based numbering domain, not a tunable cap. diff --git a/docs/css-keyless.md b/docs/css-keyless.md deleted file mode 100644 index 660f1409..00000000 --- a/docs/css-keyless.md +++ /dev/null @@ -1,245 +0,0 @@ -# CSS keyless title-key recovery (`src/css/keyless.rs`) - -## The cipher this attacks - -The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two -LFSRs **directly** from `key = title_key XOR sector_seed` (seed = -`sector[0x54..0x59]`): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from -bytes 2-4 with the pre-conditioning `r0 = r0*2 + 8 - (r0 & 7)`, and each body -byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no -title-key mangling on the content path, so the recovery is a single inversion -of the sector cipher. - -## The attack - -1. **Known plaintext → keystream.** Because descramble applies TAB1 to the - ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`. -2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1 - forward; for the first four steps deduce the LFSR0 output bytes from the - keystream (carry-tracked), reconstructing LFSR0's state. For the next six - steps clock LFSR0 normally and check it reproduces the keystream — a wrong - LFSR1 seed fails fast. -3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for - the byte shifted in) to reach the initial state, then undo the - `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5]. -4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`. - -Known plaintext for step 1 comes from the longest periodic run in the -cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at -0x80. - -## `recover_title_key_from_plain` - -Core of the recovery. `crypted` is the ciphertext starting at sector byte -0x80; `decrypted` is the matching known plaintext; `seed` is -`sector[0x54..0x59]`. On success returns the recovered 5-byte title key; -`None` if no LFSR seed reproduces the keystream. At least 10 bytes of -`crypted`/`decrypted` are required (the cipher is iterated 10 times: 4 to -reconstruct LFSR0, 6 to validate). - -## `attack_crib` - -Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest -run that repeats with a cycle length in 2..0x2F. If the run is long enough -(`plen > 3` and at least two full cycles), the plaintext at 0x80 is taken to -be that periodic run continuing forward. Returns `None` for an unscrambled -sector or one with no usable run — such a sector can be neither cracked nor -key-validated, only descrambled with an externally-cached key. - -The header is untouched by `descramble_sector`, so the crib is identical -before and after descramble: the decrypt path uses it as a per-sector -"did the cached key descramble correctly?" oracle (the predicted plaintext -must reappear at 0x80), and the cracker uses it as its known plaintext. - -## Tests - -### `synth_sector` - -Builds a synthetic scrambled sector for a given title key and seed, with -`plain` placed as the plaintext at byte 0x80, scrambled with EXACTLY the -cipher `descramble_sector` inverts. Returns -`(scrambled_sector, full_plaintext_body)`. - -### `synth_periodic_sector` - -Builds a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends in a -periodic run that continues into the encrypted region — the case -`crack_title_key` is designed to crack. - -### `recover_round_trips_known_keys` - -MANDATORY round-trip (Task C.1): synthesize a scrambled sector for a known -(title_key, seed), then assert `recover_title_key` returns a key that -descrambles the body back to plaintext. CSS title-key recovery is -well-defined up to keys that scramble identically; we assert the full body -round-trips (the true correctness property), and additionally that the EXACT -key is returned for the common case. - -### `descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with` - -`descramble_matches` is the ONLY gate between the LFSR search and a key -handed back to the caller: both `recover_title_key` and the crib-driven -`crack_title_key_inner` return a candidate only if this says the key really -descrambles the sector to the known plaintext. A body that always answered -`true` would let the first spurious LFSR-seed match through as the title -key — the ripper would then descramble the whole title with a key that opens -nothing, producing garbage rather than a "no key" error. - -Pinned both directions: the genuine key is accepted, and EVERY key one bit -away from it is rejected. The one-bit neighbours are the strongest form of -wrong key — a gate that only rejects wildly different keys would still pass -a near-miss out of the 2^16 seed search. - -### `descramble_matches_does_not_disturb_the_caller_s_sector` - -The gate is applied to a COPY: verifying a candidate must not modify the -caller's sector. `recover_title_key` runs the gate and then hands the sector -on to be descrambled for real — if verification descrambled in place, that -second descramble would run over already-transformed bytes (and, worse, a -rejected candidate would leave the sector corrupted). - -### `recover_rejects_a_sector_that_ends_inside_the_encrypted_region` - -A sector buffer that ENDS inside the encrypted region must be refused, not -sliced. `recover_title_key` slices `sector[0x80..0x8A]` unconditionally -after its length guard. The existing short-sector test uses -`SECTOR_BYTES - 1`, which is still long enough for that slice to succeed — -so the guard was never the thing producing the `None`, and dropping it (or -weakening the `||` to `&&`, which a full-length crib satisfies) changed -nothing observable. On a real short read this is an out-of-bounds panic on -the rip thread. - -### `a_buffer_longer_than_one_sector_still_yields_its_key` - -A buffer LONGER than one sector is still one sector: both entry points read -the first `SECTOR_BYTES` and must recover the key from it. Callers read DVD -data in multi-sector blocks, so an over-long slice is the normal case, not -an exotic one. A length guard that rejected it (`len > SECTOR_BYTES` instead -of `<`) would make every block-read caller silently unable to crack -anything. - -### `recover_accepts_more_than_ten_bytes_of_known_plaintext` - -`recover_title_key` accepts MORE than ten bytes of known plaintext, and uses -all of it: the extra bytes tighten the `descramble_matches` gate. The -ten-byte figure is a MINIMUM (the cipher is iterated ten times), not an -exact requirement — a guard reading it as an upper bound would reject every -caller that knows a longer crib. - -### `a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused` - -The scramble-flag gate on a sector whose BODY really is ciphertext. Both -entry points refuse a sector with `sector[0x14] & 0x30 == 0`: an -unscrambled sector has no title key to recover, and its bytes at 0x80 are -already plaintext. Every prior test of this gate used an all-zero or -all-`0x11` sector, where the recovery would have found nothing anyway — so -widening the mask test (`&` to `|`, which makes it true for EVERY flag byte) -produced the same `None` and went unseen. - -Here the sector is genuinely scrambled and its key IS recoverable; only the -cleared flag stands in the way. If the gate stops working, both functions -start returning keys for sectors the disc says are in the clear. - -`attack_crib` carries its own copy of the same gate and is the one that -actually stops the crack (`crack_title_key`'s is defensive duplication); it -also doubles as the decrypt path's cached-key oracle. - -### `descramble_matches_forces_the_scramble_flag_on_its_own_copy` - -The gate must verify a candidate against the sector's CIPHERTEXT regardless -of what the sector's own flag byte says. `descramble_matches` forces `0x10` -on its copy precisely because `super::lfsr::descramble_sector` is a no-op -when the scramble bits are clear — without that, verifying a -scrambled-but-unflagged sector compares raw ciphertext against the crib, and -every candidate key is rejected. Nothing exercised it: every fixture already -had the flag set, where forcing the bit is a no-op. - -### `descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector` - -The gate compares the WHOLE supplied plaintext, clamped to the encrypted -region. Two properties in one, because they are the two halves of -`plain.len().min(SECTOR_BYTES - ENCRYPTED_START)`: - -- it must compare beyond the first sixteen bytes, or a key that opens only - the head of the crib is accepted; and -- it must never compare past the end of the sector — a caller that knows - more plaintext than the 1920-byte encrypted region holds otherwise - indexes off the end of the buffer and panics. - -### `sector_with_trailing_run` - -Builds a sector whose clear header ends in a `period`-length repeating run -of exactly `run_len` bytes immediately before 0x80. The run is anchored to -ABSOLUTE sector offset (`sec[x] = pat[x % period]`), which is what makes -"the run continues past 0x80" a statement independent of the code under -test: the byte at `0x80 + i` of the underlying plaintext is -`pat[(0x80 + i) % period]`. Everything before the run is `0x00` (the pattern -bytes are all >= 0xD0, so the run cannot be extended backwards by accident), -and the encrypted region is filled with `0xFF` — so a crib that reads past -0x80 into "ciphertext" is immediately visible. - -### `attack_crib_predicts_the_periodic_run_continuing_past_0x80` - -KNOWN ANSWER: for a run of `run_len` bytes with period 5 ending exactly at -0x80, the crib is the run continued forward — the same ten bytes for every -run length, because the prediction depends only on the pattern and the -phase, never on how many cycles happened to be visible. The short lengths -are the load-bearing ones: at `run_len = 11` the crib window starts at 0x76 -and is only 10 bytes from the end of the header, so any drift in -`plain_start`, in `cycles * best_p`, or in the `i % best_p` wrap reads the -0xFF "ciphertext" instead of the run. - -### `attack_crib_refuses_a_run_shorter_than_two_cycles` - -A run of exactly ONE cycle (plus the trivial tail the detector counts) is -not enough to predict forward: `attack_crib` requires at least two full -cycles. Weakening that guard would let a one-off byte sequence be declared -periodic and produce a confidently wrong crib — which the decrypt path uses -as its "is my cached key still right?" oracle. - -### `attack_crib_refuses_a_buffer_shorter_than_a_sector` - -`attack_crib` indexes `sector[0x7f - j]` with no per-access bound, so its -own length guard is the only thing between a short buffer and an -out-of-bounds read. Nothing reached it: every caller-level test used a full -sector, and the entry points' guards fire first. - -### `attack_crib_survives_a_header_that_is_periodic_to_offset_zero` - -A header that is periodic ALL THE WAY to offset 0 must not walk the -backward scan off the front of the sector. The detector counts backwards -from 0x7f while `j < 0x80`. On a fully periodic header the run never -breaks, so `j` reaches 0x7f and the bound is the ONLY thing that stops it — -one step further and `0x7f - j` underflows a `usize` and panics. A constant -or fully-patterned 128-byte header is ordinary DVD data (padding, a run of -zeros), not a crafted input, and every existing fixture had a -filler/run boundary well before offset 0 that stopped the scan early. - -### `attack_crib_is_independent_of_the_encrypted_region` - -The crib is read from the CLEAR header only. A run that reaches 0x80 must -predict from the header bytes, never from the encrypted region — the -previously-fixed bug this function's doc comment records. Pinned by -rewriting the encrypted region and requiring the crib not to move. - -### `recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input` - -`recover_title_key_from_plain` unconditionally builds a 10-byte keystream -buffer from `crypted[0..10]` and `decrypted[0..10]`, so its length guard is -the only thing standing between a short slice and an index-out-of-bounds -PANIC. Nothing reached that guard before: `recover_title_key` rejects -`plain.len() < 10` at its own door and always hands on exactly ten -ciphertext bytes, and `crack_title_key_inner` always passes a fixed -`[u8; 10]` crib. The guard is a live contract for any future caller and was -executed by no test at either boundary. - -### `recover_title_key_from_plain_xors_the_sector_seed_back_out` - -The seed XOR-back (`recover_title_key_from_plain`'s last step) is what turns -the recovered LFSR key into the TITLE key: `key ^= sector_seed`. Pinned as a -known answer across seeds that differ only in one byte — the same -ciphertext/plaintext pair therefore must yield title keys differing in -exactly that byte. Without this, a body that ORed the seed in (or dropped -the step) still round-trips on any fixture whose seed is zero, and on the -non-zero ones the failure looks like "no key found" rather than a wrong -step. diff --git a/docs/css-mod.md b/docs/css-mod.md deleted file mode 100644 index edb6bd42..00000000 --- a/docs/css-mod.md +++ /dev/null @@ -1,311 +0,0 @@ -# `src/css/mod.rs` — extended notes - -Overflow detail relocated from doc comments in `src/css/mod.rs` (kept short -there per the comment-guard caps). Function/item names below match the code. - -## `CSS_LOCKED_BAIL` - -The bus-auth read gate is global (all-or-nothing), so a run this long of -`05/6F/03` (CSS-locked) reads means the gate is shut and nothing here is -crackable — bail instead of grinding the full 50_000-sector budget. That -grind is what made rc5 appear to hang on a wedged USB bridge. - -## `crack_key` - -Recovers the CSS title key with no keys, by scanning scrambled sectors and -running the known-plaintext attack (see the `keyless` module). The crib comes -from the periodic-run detector: a scrambled sector's cleartext region -(bytes 0x00..0x80) often ends in a short-period repeating run (stuffing / -constant fill); the attack assumes that run continues across the 0x80 -boundary into the encrypted region, giving the known plaintext the 2^16 LFSR -recovery needs. Scans up to 50000 sectors across the extents and returns the -first sector that yields a key — no player keys, no disc-key crack. Works on -a live drive (after bus-auth unlocks reads) and on disc images alike. - -This convenience form runs to completion (no cancellation) and returns just -the key; callers needing an operator-Stop / watchdog cancel, or the -three-way `CrackOutcome` (to distinguish "unencrypted" from -"encrypted-but-uncracked"), use `crack_key_outcome`, which takes a `halt` -token. - -## `CrackOutcome` - -Distinguishes the three cases the bare `Option<CssState>` conflated (and -which caused a silent-failure bug: scrambled-but-uncracked content was -treated as "unencrypted" and muxed as plaintext garbage at exit 0): - -- `Cracked` — a scrambled sector yielded a title key. -- `Unencrypted` — NO scrambled sector was seen across the scanned extents - (`is_scrambled_pack` never true): the content is genuinely plaintext, so - proceeding without a key is correct. -- `ScrambledUncracked` — scrambled sectors WERE seen but no key could be - recovered (the recovery found no crackable crib, or the scrambled region - was unreadable). The content is encrypted; muxing it as plaintext would - emit garbage, so callers MUST surface a hard error instead of falling - through to "unencrypted" — the per-title `crate::error::Error::CssKeyMissing` - when it is ONE title's own re-crack that failed (skippable: a sibling VTS - may still crack), or the disc-level `crate::error::Error::CssNoDiscKey` - when it is the disc-wide scan (`Disc::css_error`, every title fails - identically). - -## `resolve_dvd_title_key` - -The SINGLE place every DVD read path obtains a title key, so the file-backed -mux highway (`crate::build_iso_pipeline`) and the live-drive single-pass -`crate::DiscStream` descramble a DVD identically ("reading is reading"). CSS -keys are per-VTS and crackable from the scrambled data itself, so a -`None`/MPEG-PS title cracks its own key here, in playback order over -`extents`. Everything else is left untouched: - -- AACS keys (HD-DVD `.evo` is also MPEG-PS but arrives as `Aacs`) — no CSS. -- a title that already carries a key — nothing to resolve. -- a genuinely clear DVD (no scrambled sector) — stays `None`, a mux no-op. - -A scrambled-but-uncrackable title is a hard `crate::error::Error::CssKeyMissing`, -never a silent scrambled-passthrough mux. That code is the PER-TITLE one -(`error::is_skippable_title_stub`), which is correct here: this function -cracks ONE title's own extents, and another VTS on the same disc may still -yield its key, so an all-titles rip skips this title and finishes the rest. -The whole-disc failure is `crate::error::Error::CssNoDiscKey`, raised by -`Disc::ensure_decryptable_keys` from the scan's `css_error`. - -## `crack_key_scan` (private) - -Tracks a `saw_scrambled` flag so a scrambled-but-uncracked disc is -distinguished from a genuinely-unencrypted one (the `crack_key` `Option` -wrapper collapses both to `None` via `CrackOutcome::into_state`). - -## `descramble_sector` - -A no-op unless the sector is a scrambled MPEG-2 PS PACK. The pack start code -is checked, not just the byte 0x14 flag bits, for the same reason -`descramble_region` checks it: 0x14 only means "scrambling control" inside a -pack, and in an IFO it is whatever that format stores there. A real -`VIDEO_TS.IFO` sector holds 0x15 there while starting `00 26 00 00`; -descrambling it destroyed 1912 of its 2048 bytes, and because that sector -carries TT_SRPT the disc enumerated 38 titles while an image decrypted from -it enumerated 10, silently, at exit 0. - -This function has no callers inside the crate, but the module-level example -prescribes it — so the crate's own documented guidance led straight into -that defect. Making the guard part of the function, rather than something -each caller must remember, is what keeps the safe path the easy one. - -## `descramble_region` - -Descrambles a whole CSS buffer in place, re-cracking the title key on a VOB -region boundary. `title_key` is a CACHE of the last crack, not a fixed disc -key: it changes per VTS/VOB region, so it is validated on every scrambled -sector and re-cracked on a miss (the standard on-demand per-region rekey). - -This CSS key acquisition is intrinsic to the cipher — CSS has no external key -source, the ONLY way to a title key is cracking the data — so it lives with -the CSS primitives and runs inside `decrypt::decrypt_sectors` (a public, -self-contained CSS decrypt), NOT at the post-decrypt recovery seam that AACS -key-fetch and FMTS segment-skip use (those consume external inputs). - -The clear header (`<0x80`) is never scrambled, so its periodic crib predicts -the plaintext at `0x80`. Descramble with the cached key; if the crib fails to -reappear the key region changed (or the primed key was wrong) — restore the -ciphertext, re-crack from this very sector, and descramble again. A crib-less -sector (no periodic run) can be neither validated nor cracked, so it rides -the cached key — correct, because it lives in the same region as the nearby -crib sector that set the cache. - -This section used to document an `Error::DecryptFailed` for the case where a -sector's crib rejects the cached key and the re-crack from that sector also -fails. That behaviour was tried and REVERTED: crib mismatch plus crack -failure is the signature of a crib FALSE POSITIVE, not of a stale key, and -failing there made real discs unrippable. The code now descrambles with the -cached key and returns `Ok`. - -## `has_scramble_flag_bits` - -Deliberately NOT called `is_scrambled`. Byte 0x14 only means "scrambling -control" inside an MPEG-2 Program Stream pack; in an IFO, UDF or ISO 9660 -sector it is whatever that format stores there. Treating the flag alone as -proof of scrambling is what destroyed 1912 bytes of a real disc's -`VIDEO_TS.IFO` — the sector carrying TT_SRPT — so the disc enumerated 38 -titles and an image decrypted from it enumerated 10, silently, at exit 0. - -Callers want `is_scrambled_pack`. It asks the same question and also -requires the pack start code, which every genuinely scrambled VOB sector -carries and no IFO sector does. - -It stays public only because an integration test asserts the flag -extraction directly. It has no production callers, and its previous doc -comment claimed one (`decrypt::decrypt_sectors`) that did not exist — so the -name was an invitation and the documentation was an argument for accepting -it. - -## `is_scrambled_pack` - -The HARDENED test the crack scan uses to set its `saw_scrambled` evidence -flag (Fix 3). - -`has_scramble_flag_bits` keys solely on bits 4-5 of byte 0x14. That byte is -only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack, -which ALWAYS begins with the 32-bit pack-start code `00 00 01 BA` at offset -0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title) -can carry arbitrary bytes that happen to set bits 4-5 of byte 0x14; trusting -byte 0x14 alone there would flip the scan's `saw_scrambled` gate and make a -genuinely-UNENCRYPTED title report `ScrambledUncracked` — a false E7023. - -Requiring the pack-start signature FIRST means only a sector that is -structurally a DVD video pack can be counted as scramble evidence. This does -NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real -scrambled feature is made of valid PS packs, so its scrambled sectors still -pass this check and still drive `ScrambledUncracked` when no key cracks. - -The pack-start check alone is not enough, and the sector's PES `stream_id` -(offset 0x11, the byte after the 14-byte pack header's `00 00 01` PES -prefix) is the second load-bearing gate. CSS scrambles ONLY elementary -streams — video (`0xE0..=0xEF`) and private_stream_1 audio/subpicture -(`0xBD`) — and it never touches the clear header, so byte 0x11 is the TRUE -stream_id even on a scrambled sector. The MPEG-PS structural packets that -are never CSS-scrambled — system_header (`0xBB`), padding (`0xBE`) and -private_stream_2 (`0xBF`, DVD PCI/DSI navigation) — must be excluded, -because on those the byte at 0x14 is NOT a PES scrambling-control field but -raw payload/structure whose bits 4-5 land set by chance. - -This is the DETECTION defect a decrypted HD-DVD tripped: an HD-DVD `.evo` is -MPEG-PS exactly like a DVD `.vob`, and its RDI navigation packs are -private_stream_2 (`0xBF`) whose payload byte at 0x14 routinely has bits 4-5 -set. With no `0x11` gate those nav packs flipped the crack scan's -`saw_scrambled` flag on a disc that carries no CSS at all; the crack then -found no key (there is none) and the scan returned `ScrambledUncracked`, -hard-failing a perfectly good HD-DVD with `CssKeyMissing` — E7023. Excluding -`0xBB/0xBE/0xBF` at 0x11 makes the evidence gate match what the crack itself -can act on (the recovery only recovers a key from a scrambled ES pack), so a -decrypted HD-DVD now scans to `Unencrypted` and muxes cleanly. - -This does NOT weaken the genuine "encrypted but uncrackable" hard-fail on a -real DVD: a scrambled DVD feature is made of video (`0xE0..`) and -private_stream_1 (`0xBD`) packs, none of which are excluded, so its -scrambled sectors still set `saw_scrambled` and still drive -`ScrambledUncracked` when no key cracks. Byte 0x11 is in the clear header, -so a scrambled DVD pack can never masquerade as `0xBB/0xBE/0xBF`. - -The DESCRAMBLE path gates on this same function — `descramble_sector` and -`descramble_region` both call it, not the raw flag test — because the raw -test does not merely mis-skip a sector there: it descrambles one that was -never scrambled and destroys it. The measured case is written up above under -`descramble_region`: a `VIDEO_TS.IFO` sector holding 0x15 at offset 0x14 lost -1912 of its 2048 bytes, taking TT_SRPT with it, and the disc's 38 titles -became 10 — silently, at exit 0. One gate, both paths. - -## Tests (`mod tests`) - -Detail relocated from test doc comments: - -- `a_crib_false_positive_keeps_the_cached_key_rather_than_failing`: - `attack_crib` is a heuristic — it finds a periodic run in the unscrambled - header and predicts the run continues past 0x80. When that prediction does - not hold, the crib reports a mismatch even though the cached key is - correct, and the re-crack then fails BECAUSE the crib was never valid. So - this combination is the signature of a crib false positive, not of a stale - key, and the cached key remains the best available evidence. This test - exists because a prior round read the same code as "descrambling with a - key we just proved stale" and made it `DecryptFailed` to match the AACS - path; real DVDs hit this constantly, and the change made a real disc - unrippable (no unit test caught it — the real-media acceptance gate did). - CSS is not AACS: an AACS unit key either opens a unit or does not, whereas - a CSS title key is recovered from data whose recoverability varies sector - by sector. - -- `has_scramble_flag_bits_short_buffer_is_false_no_panic`: grounding — - `sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0` short-circuits so - a short buffer never reads index 0x14. Mutation: swapping operand order - would panic indexing a 20-byte slice; this test catches it. - -- `has_scramble_flag_bits_uses_bits_4_5_only`: grounding — - `(sector[0x14] >> 4) & 0x03`. Mutation: widening the mask to `& 0x0F` makes - 0x40 report scrambled; the 0x40 assert catches it. - -- `has_scramble_flag_bits_exact_sector_length_accepted`: grounding — - `sector.len() >= 2048`. Mutation: `>= 2048` → `> 2048` makes an exact - 2048-byte scrambled sector report false; this catches it. - -- `is_scrambled_pack_requires_pack_start_signature`: Fix 3 hardening — - `is_scrambled_pack` requires BOTH the pack-start code and the 0x14 bits, so - a clear/nav-only stub with stray 0x14 bits isn't scramble evidence (else a - genuinely unencrypted title reports the false E7023). - -- `is_scrambled_pack_excludes_nav_and_structural_stream_ids`: detection fix — - `is_scrambled_pack` must exclude system_header/padding/private_stream_2 by - `stream_id` at 0x11, since CSS never scrambles those and byte 0x14 there is - raw payload, not a scrambling-control field. This is the exact - decrypted-HD-DVD defect (an `.evo` RDI pack is 0xBF with pack-start and - 0x14 bits set); the video/private_stream_1 cases prove the exclusion - doesn't weaken real-DVD detection. - -- `MockSource` fields `lock_all`, `crackable`, `short_read`, `stream_id`: model - a CSS-locked drive (`05/6F/03` on every read), a designated crackable LBA - serving a full synthetic scrambled sector, a short-read count (the - `recovery: true` short-read contract, `Some(0)` being the degenerate - spin-guard case), and the PES `stream_id` written at 0x11 (used to model an - HD-DVD `.evo` RDI nav pack via `PRIVATE_STREAM_2`). - -- `crackable_sector`: builds a crackable scrambled sector whose cleartext - header (0x59..0x80) carries a periodic run continuing across the 0x80 - boundary, mirroring the `synth_periodic_sector` fixture in the keyless - tests but built from the crate-internal `scramble_sector`. - -- `a_source_that_returns_zero_sectors_still_obeys_the_scan_budget`: `tried` - increments only per inspected sector inside `for s in 0..usable`; an - `Ok(0)` never runs that loop, so `advance` is forced to 1 to keep the - cursor moving and the extent's full declared length is walked. Mutation: - deleting the `tried` charge in the `usable == 0` arm goes red at 60,000 - reads for a misbehaving/adversarial source. - -- `crack_key_caps_total_tries_at_50000`: grounding — `max_tries = 50_000`, - `tried += 1` before the read, loop guard `tried < max_tries`. Mutation: - widening the cap, or removing the increment, both go red. - -- `crack_key_budget_is_shared_across_extents`: grounding — `tried` is - declared outside the extent loop with a shared `if tried >= max_tries` - break. Mutation: moving the declaration inside the loop would give each - extent its own budget (80,000 total instead of 50,000). - -- `crack_key_scans_from_extent_start_lba`: grounding — - `reader.read_sectors(ext.start_lba + i, ...)`. Mutation: dropping - `ext.start_lba +` would start LBAs at 0 instead of the extent's start. - -- `crack_key_continues_past_read_errors`: grounding — a read `Err` falls - through to the next sector rather than aborting. Mutation: propagating the - error with `?` would stop the scan after the first failure. - -- `crack_key_empty_extent_reads_nothing` / `crack_key_no_extents_is_none`: - grounding — `while i < ext.sector_count` / `for ext in extents` are no-ops - on empty input; an off-by-one (`<=`) or a pre-loop read would break these. - -- `crack_outcome_reaches_cracked_with_span`: audit gap "MockSource never - yields a crackable sector" — drives the full `crack_key_scan` over a - synthetic ISO to exercise the `Cracked` branch and `crack_span` recording - end-to-end, previously untested. - -- `is_scrambled_uncracked_is_true_for_that_case_and_false_for_the_other_two`: - a prior round introduced the three-way split precisely because conflating - the cases made an uncrackable disc exit 0 with garbage output; earlier - tests only asserted the TRUE direction, so a predicate answering "yes" - unconditionally was indistinguishable from a correct one. All three - outcomes here come from real `crack_key_outcome` scans, not hand-built enum - values. - -- `resolve_dvd_title_key_*` tests: `resolve_dvd_title_key` is the SINGLE - shared per-title CSS step both read paths (`build_iso_pipeline` multi-pass - and `DiscStream::new` single-pass) call, so these pin its full contract at - the shared boundary — crack success, hard-fail on scrambled-uncrackable - (never leaving `keys` as `None`, which would mux scrambled bytes as - plaintext), the decrypted-HD-DVD RDI-pack regression (end to end), `--raw` - passthrough, the AACS-keys-untouched gate, the clear-DVD no-op, and the - halt-cancellation-surfaces-as-Halted fix (not a truncated-scan verdict). - -- `all_locked_synthetic_iso_yields_css_key_missing_signal`: audit §2/§5 #7 — - pins that an all-locked synthetic ISO across MULTIPLE extents produces - `ScrambledUncracked`, the exact signal `disc/mod.rs` converts into - `disc.css_error = Some(Error::CssKeyMissing)`. - -- `recrack_succeeds_on_other_vts_extents`: audit gap "success path missing" - — the prior re-crack test only covered the locked→None path; this proves a - key cracked for one VTS is genuinely re-derived (not reused) for another. diff --git a/docs/ctrm.md b/docs/ctrm.md deleted file mode 100644 index 93d10ac5..00000000 --- a/docs/ctrm.md +++ /dev/null @@ -1,32 +0,0 @@ -# Warner CTRM label parser — relocated rationale - -Referenced from `src/labels/ctrm.rs` unit tests via `// See docs/ctrm.md` pointers. - -## Immunity pin: no analog of the `paramount` forced_sub false positive - -Immunity pin against the defect measured in the `paramount` parser, where a -vendor `forced_sub` cell hung off a FULL dialogue track's own slot to say -"this track also contains forced signs", and reading that cell as "this -track is forced" flagged 30 MB dialogue tracks forced. - -This format cannot express that. The forced signal is not a flag beside a -track's entry — it IS the entry's stream-kind token, drawn from a closed -vocabulary in which `subtitle_production` (the full dialogue track) and -`subtitle_narrative` (the forced-narrative track) are mutually exclusive -alternatives in the same position. A row is one or the other; there is no -cell a full track can carry to acquire the qualifier, so the paramount -failure mode has no encoding here. - -Mutation: give `subtitle_production` a `Forced` qualifier, or add a forced -side-flag that both kinds may carry. - -## Immunity pin: stream numbers come from the row, not a counter - -`language_streams.txt` states each stream's number in field 3, so a row the -parser cannot use is simply dropped — it can never renumber the rows behind -it. This is the property that keeps this parser out of the STN-slot-shifting -failure mode that bites parsers which count positionally: there, a skipped -entry silently pulls every later label one stream forward. - -Mutation: replace `parts[2]` with a running per-type counter → the three -unusable rows in the corresponding test collapse the survivors onto 1/2 and 1. diff --git a/docs/dbp.md b/docs/dbp.md deleted file mode 100644 index b26a23d1..00000000 --- a/docs/dbp.md +++ /dev/null @@ -1,39 +0,0 @@ -# `labels::dbp` - -## Implementation - -Iterates `CpInfo::Utf8` constant-pool entries rather than raw -byte-scanning each class file. Equivalent label coverage (the literal -`TextField,...` strings live in the CP as Utf8 entries) with no -false-positive risk from method bytecode or attribute names that -happen to contain `TextField,`. Language / purpose / qualifier -classification lives in `super::vocab` so all Java-parser families -share one source of truth. - -The parser ignores any prefix before the first `TextField,` occurrence -in a constant string — whatever string-pool ordering placed ahead of -it is irrelevant. - -## `MAX_LABEL_BYTES` rationale - -The label is an owned copy of a slice of a `CONSTANT_Utf8_info` entry, -whose `length` field is a `u16` (JVMS §4.4.7) — so a single crafted -constant contributes up to 65535 bytes, and the `u16` stream-number -keyspace admits 65536 of them per type. - -Headroom: real dbp menu labels are short display names — "English Dolby -Atmos" (19 bytes), "Spanish 5.1 Dolby Digital" (25). The longest plausible -retail string ("Portuguese (Brazilian) 5.1 Dolby Digital Plus") is 45 -bytes. 256 leaves >5x headroom over that, and any string past it is menu -geometry or padding, never a language name — `vocab::lang` would not -resolve it anyway. - -## `MAX_LABELS_PER_TYPE` rationale - -The keys come from `parse::<u16>()` on disc bytes, so all 65536 slots per -type are reachable; paired with `MAX_LABEL_BYTES` this bounds the whole -scan at 2 x 512 x 256 bytes. - -Headroom: the BD STN_table admits at most 32 primary audio and 32 PG -streams per playlist, and dbp emits one menu TextField per stream. 512 -leaves 16x headroom over the spec maximum. diff --git a/docs/decrypt.md b/docs/decrypt.md deleted file mode 100644 index efd81fad..00000000 --- a/docs/decrypt.md +++ /dev/null @@ -1,500 +0,0 @@ -# decrypt.rs — relocated rationale - -Overflow prose that would not fit under `ci/comment-guard.py`'s caps, -moved here per file with a `// See docs/decrypt.md — <topic>` pointer -left at the original site. - -## Parallel AACS decrypt - -Each AACS aligned unit (6144 bytes) is decrypted INDEPENDENTLY of every -other unit — per-unit key derivation from the unit_key plus the unit's -own first-16-byte header. There is no cross-unit dependency, so a -buffer of N units can be decrypted on N threads in parallel via a -persistent rayon thread pool. - -Small buffers (< `PARALLEL_MIN_UNITS` units) fall through to the serial -path to avoid pool dispatch overhead beating the per-unit AES work. - -## Thread-count configuration — three layers - -Resolution order (highest wins): -1. The most recent `set_decrypt_threads` call with `n > 0`. Calling - this *replaces* the live thread pool — useful for a settings-page - slider in a long-running daemon. -2. `FREEMKV_THREADS` env var, if set and `> 0`. Single knob covering - decrypt today, intended to also drive any future input-side / - output-side worker pools. -3. Default: all available cores. Algorithm optimisation comes first — - we measure single-thread performance to find serial bottlenecks - before throwing parallelism at it — but once a pool is engaged we - use the whole box. Hard cap at `MAX_THREADS` (rayon stack memory). - -## DECRYPT_POOL - -Current rayon pool. `RwLock<Option<Arc<...>>>` so that -`set_decrypt_threads` can swap the pool out without leaking the old -one and without blocking ongoing decrypt work (in-flight calls hold an -`Arc` clone via `decrypt_pool` and finish on the old pool; new calls -pick up the new pool). - -## decrypt_pool() - -Get (or lazily build) the active rayon thread pool. Returns an `Arc` -so in-flight work survives a concurrent `set_decrypt_threads` swap. - -Returns `None` if the pool cannot be built (e.g. the OS refuses the -worker threads under a pid/thread limit). The caller falls back to the -serial decrypt path — library code never panics here. - -## unit_is_our_phase - -Does this unit belong to the phase we hold the key for? - -An FMTS forensic segment interleaves two variants at the unit level: -the disc carries both, and we hold the key for exactly one parity. -Decrypting the alternate half with our key produces garbage; leaving -it as ciphertext is correct, because the muxer drops untouched -ciphertext cleanly. - -`Phase::All` means the whole range is ours (the non-forensic case). - -This lived inline inside `apply_aacs_map`'s per-unit closure, where -nothing could reach it: a mutation run flipped the `-` to `+` and the -`/` to `*` in the index arithmetic and every test still passed. -Getting either wrong silently decrypts the wrong half of a forensic -segment. - -## AacsKeyMap (proactive AACS key-selection map) - -Which held unit key decrypts each LBA of a title's encrypted content, -decided ONCE before mux from the disc's CPS-unit (and, later, FMTS -segment) structure — never by trial-decrypt-and-check per unit at mux -time. - -This is the pivot that ends the mux "key-server storm": the old path -decrypts a unit, checks whether the plaintext looks like clean MPEG-TS, -and — because authored-bad content never reaches that bar — concludes -"wrong key, fetch a fresh one" and re-asks the key service for units it -already holds the correct key for. There is NO per-unit byte pattern -that separates "correctly decrypted but authored-bad" from "still -encrypted", so that check is unanswerable. The map removes the -question: we resolve one key per CPS unit / segment up front (see -`resolve_mux_key_map`), record which LBA ranges each covers, and at mux -time simply "decrypt this LBA with key K" and trust it — bad TS is the -muxer's concern, exactly as for a physically-read clear disc. - -Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` -pool, sorted and disjoint. The map is a POSITIVE list: an LBA in no -range is passed through untouched (no default key). How a single-CPS -disc is mapped depends on the caller: the whole-disc EXTRACT path uses -one blanket range `(0, u32::MAX, 0)` so every encrypted unit — parsed -title or orphan clip — resolves to key 0; the per-title MUX/sweep path -(`resolve_mux_key_map` → `content_map`) maps only the title's own -extents, so an orphan clip outside them is left as pass-through. Either -way, clear nav/filesystem sectors (encrypted-flag off) pass through. - -## AacsKeyMap::read_plan - -Build the FMTS **read plan**: the title's aligned units filtered down -to only the units this rip must actually read — every default / CPS -unit, plus, inside each forensic segment, ONLY our-phase (`Phase::Even` -/ `Phase::Odd`) units. The alternate-phase units are a different device -group's variant: a licensed player never reads them, and neither do we. -They are omitted from the plan entirely, so they are never fetched, -decrypted, or handed to the demux — the demux therefore sees one -gapless our-variant stream, with no ciphertext to trip a -concealed-gap resync (the old behaviour that dropped good frames -around every segment). - -`extents` are the title's clip extents (unit-aligned in the interior; a -shorter tail is ordinary content and always kept). `unit_sectors` is -the AACS aligned-unit size in sectors (3). Contiguous kept units -coalesce into as few extents as possible so the producer still issues -large sequential reads across default content; only inside a ~480 KB -forensic segment do reads become unit-granular (every other unit). A -map with no forensic (Even/Odd) range returns `extents` unchanged — the -common disc is not touched. - -The parity test is byte-identical to the decrypt hot loop -(`(unit_lba - range_start) / unit_sectors`), so a unit kept here is -exactly a unit `decrypt_sectors_mapped` would open, and vice-versa. - -## apply_aacs_map - -AACS scheme step: apply `map`'s per-unit keys to `buf`, in-place. -`base_lba` is the absolute LBA of `buf`'s first sector; each aligned -unit (3 sectors) is decrypted with the key the map assigns to its LBA. -There is NO key trial and NO `is_clean` verdict: the map already -decided the key from disc structure, so this applies it and moves on — -a unit that decrypts to authored-bad TS passes through for the muxer to -drop, never re-fetched. - -A SCHEME, not a policy. It reports what it could not open by returning -`Err(DecryptFailed)`; the decision that an unopenable unit must never -be emitted belongs to `decrypt_span`, which is the one place that -decides it for every scheme. A map index outside the held pool is -likewise a fail-loud `Error::DecryptFailed`: the resolver's job is to -guarantee every selectable index is present, so a gap here is a -resolver bug, not silent loss. - -## decrypt_sectors — CSS / clear detail - -For CSS: descrambles per 2048-byte sector, self-cracking the title key -from the data (no external input). For `None`: a no-op. For AACS: -**always** returns `Err(DecryptFailed)` — AACS decrypts exclusively -through the resolved key map (`decrypt_sectors_mapped`), which keys -every content unit up front and fails at RESOLVE time when a key is -missing. Reaching this arm with AACS keys means a reader was built -without installing its map (a bug), so it fails loud rather than apply -a guessed key. - -`unit_key_idx` is a legacy parameter kept so the CSS / `None` wrapper -signature stays stable; it is ignored (the CSS arm self-gates on its -per-sector scramble flag). Returns `Err` if decryption was expected but -impossible; never produces silently corrupted output. The `usize` -return is a legacy unverified-byte count that is always `0` for the CSS -/ `None` arms. - -## decrypt_span — orchestrator rationale - -THE decrypt orchestrator. Every path into this crate's decryption goes -through here. - -How a disc decrypts is one process — resolve a key for this span, -apply it, and refuse if no key can be proven. Only the resolve-and-apply -step is scheme-specific. This function owns the loop and the refusal; -the schemes below supply only what genuinely differs between AACS, CSS -and clear media. - -That split exists because its absence caused six separate defects in -one release. There used to be TWO top-level paths — this one for CSS -and clear, and a wholly separate `decrypt_sectors_mapped` for AACS -whose arm here was a bare `return Err` stub — so each scheme decided -its own answer to "there is no key for these bytes" and nothing held -them to the same one. CSS drifted to descrambling with a key it had -just proven stale; the mapped path drifted to passing an unkeyable -encrypted unit through as ciphertext. Both looked like success to the -caller. - -Adding a scheme means adding an arm here, which means answering the -refusal question. That is the point. - -## Tests — relocated rationale - -### a_forensic_read_plan_drops_units_the_full_extents_include - -`DecryptKeys::None` is a no-op even with a content map + scrambled -bytes. A forensic map's read plan is NOT the extents it was given — -and that is the precondition the mux's provenance guard keys on. - -A clip's feed span is measured over the FULL extents at scan time, -while the mux reads this reduced plan, so the byte offsets stamped on -frames and the offsets recorded in the spans describe different -streams. The deficit accumulates, so every frame after the first -segment resolves to an earlier clip than it came from. The spans still -tile each other, so the tiling check cannot see it; the mux compares -the plan against the full extents instead and stops trusting -provenance when they differ. - -### content_gate_css_keys_is_noop - -CSS ignores the content gate (it lives in the AACS arm) and always -reports `0` — confirming the gate is a no-op for CSS and the read -stays scheme-agnostic (the litmus test: adding CSS verify touches only -the CSS arm, never the read). - -### content_gate_css_actually_descrambles_the_buffer - -`decrypt_sectors_in_content` is the entry point `DecryptingSectorSource` -dispatches to whenever a content map is installed -(`sector/decrypting.rs` line ~211), so it is on the live read path for -every mapped rip. It must actually DECRYPT. The two `_is_noop` tests -above only assert its `usize` return is `0` — which is what a body -replaced by `Ok(0)` also returns, so neither one constrains it at all. - -Here a genuinely scrambled CSS sector goes in and the CONSTRUCTED -plaintext must come out. Anything that skips `css::descramble_region` — -including a body that just reports `Ok(0)` — leaves ciphertext in the -buffer and the caller muxes scrambled MPEG at exit 0. - -Expected bytes come from the plaintext this test built BEFORE -scrambling (CSS scrambles only 0x80..2048; the header stays clear), not -from re-running any descramble routine. - -### content_gate_aacs_keys_fail_loud_not_ok_zero - -The AACS arm of the same entry point must fail LOUD. Under the -keymap-only model AACS decrypts exclusively through -`decrypt_sectors_mapped`; reaching this wrapper with AACS keys means a -reader was built without installing its key map, and continuing would -hand the caller ciphertext under an `Ok`. `DecryptFailed` is the -correct verdict per the function's own contract — it must not be -softened into a success with a zero count. - -### css_region_change_recracks_the_title_key - -CHARACTERIZATION (recovery refactor safety net): the CSS arm's -per-region re-crack (the `title_key` cache is stale for a new VOB -region → restore ciphertext, `crack_title_key` this sector, -re-descramble). Two crackable sectors scrambled under DIFFERENT keys -sit back-to-back; the cache is primed to the FIRST key. Sector 0 rides -the cache (crib matches); sector 1 must trip the crib mismatch and -re-crack to its own key. Both must land correct plaintext, and the -cache must end on region 1's key. - -This behaviour currently lives inline in `decrypt_sectors` (the `Css` -arm). It is the delicate logic the recovery refactor will move to the -input-stream seam, so it must stay green byte-for-byte across that -move. - -### aacs_scrambled_trailing_partial_is_rejected - -Whole leading unit plus a SCRAMBLED trailing partial that is FLAGGED -encrypted in its clear seed (the malformed danger case): an encrypted -unit split across an extent boundary cannot be CBC-decrypted -standalone. The mapped decrypt must fail loud with `DecryptFailed` -rather than emit the ciphertext partial as clear. Exercises the real -shipping path (`decrypt_sectors_mapped`) and its trailing-partial -guard. - -### aacs_clear_trailing_partial_passes_through - -A CLEAR trailing partial (encrypted flag NOT set) is a legitimate -content tail and must pass through, never trip the guard above. - -"Passes through" means byte-for-byte unchanged, not merely `Ok`. -Asserting only `is_ok()` let a mutant that corrupts the clear partial -while still returning `Ok` pass — which is the whole failure this test -names. Mutation: XOR any byte of the tail before returning -> the -snapshot comparison fails. - -### none_keys_is_noop - -`DecryptKeys::None` is a pure no-op: the buffer must be returned -byte-for-byte unchanged with Ok, regardless of content (even content -that looks scrambled). - -Grounding: the `DecryptKeys::None => {}` match arm does nothing. -Mutation: replace the empty arm with a call that mutates buf -> the -unchanged assert fails. - -### is_encrypted_matches_variant - -`is_encrypted` reflects the variant: None -> false, Css/Aacs -> true. - -Grounding: `!matches!(self, DecryptKeys::None)`. -Mutation: invert the `!` -> None reports true, this fails. - -### css_descrambles_with_title_key - -The CSS path descrambles each 2048-byte sector with the title key. A -scrambled sector run through decrypt_sectors must come back to its -plaintext body (keystream XOR is involutive), proving the title key is -actually applied. - -Grounding: `DecryptKeys::Css { title_key } => for chunk in -buf.chunks_mut(2048) { descramble_sector(title_key, chunk) }`. -Mutation: change `chunks_mut(2048)` to `chunks_mut(2049)` or pass a -fixed wrong key -> the body no longer matches the plaintext. - -### css_processes_every_sector_in_buffer - -The CSS path processes EACH 2048-byte sector independently in a -multi-sector buffer. Two scrambled sectors (with different seeds) in -one buffer must both round-trip — pinning that the loop steps by 2048 -and applies the key to every sector, not just the first. - -Grounding: `for chunk in buf.chunks_mut(2048)`. -Mutation: change the loop to descramble only the first chunk (e.g. -`.next()`) -> the second sector stays scrambled, assert fails. - -### css_rekeys_when_title_key_region_changes - -CSS title keys are per-VTS/VOB region: a real disc holds DIFFERENT keys -for different regions and the only way to get each is to crack it. The -decrypt path must re-crack when the cached key stops descrambling (its -crib no longer reappears at 0x80) instead of blindly applying one key -across a region boundary — the bug that pixelated every freemkv DVD -rip. - -Two sectors scrambled under DIFFERENT keys, cache primed to ONLY the -first (exactly what the one-shot scan crack leaves). Sector 0 validates -+ descrambles with the cached key; sector 1's cached-key descramble -fails the crib, so the path re-cracks sector 1's own key and recovers -its plaintext. Before the fix (blind single-key apply) sector 1 was -garbage. - -Grounding: the CSS arm's `attack_crib` → `chunk[0x80..] != crib` → -`crack_title_key` → `*title_key = fresh` rekey. -Mutation: drop the rekey branch (apply the cached key always) → -sector 1's body no longer matches its plaintext; this fails. - -### css_leaves_clear_sector_unchanged - -The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte -untouched — descramble_sector early-returns on a zero flag. A clear -sector mixed into the buffer must not be corrupted. - -Grounding: descramble_sector returns immediately when -`(sector[0x14] >> 4) & 0x03 == 0`. -Mutation: remove that early return in lfsr.rs -> a clear sector would -be XORed with a keystream and change; this fails. - -### css_empty_buffer_is_ok - -CSS decrypt always returns Ok (it cannot fail — descrambling is XOR, -no key validity check), even for an empty buffer. - -Grounding: the CSS arm has no `return Err` path; `chunks_mut` over an -empty slice is a no-op; the function ends `Ok(())`. -Mutation: make the CSS arm return Err -> this fails. - -### aacs_mapped_out_of_range_key_idx_errors - -A map that selects a key index OUTSIDE the held pool must fail loud -with DecryptFailed — never silently apply a wrong key or pass -ciphertext through. This validates `decrypt_sectors_mapped`'s up-front -`key_indices()` bounds check (the real shipping AACS decrypt path). - -Mutation: drop the `unit_keys.get(idx).is_none()` guard → the -out-of-range index would not error; this fails. - -### aacs_via_unmapped_decrypt_sectors_fails_loud - -SAFETY NET: reaching the CSS/`None` wrapper (`decrypt_sectors`) with -AACS keys means a reader was built with no map — a bug. It must fail -loud, never apply a guessed key. (AACS decrypts exclusively via -`decrypt_sectors_mapped`.) - -### read_plan_forensic_reads_only_our_phase_units - -FMTS: a forensic Even segment drops exactly its alternate (odd) units -from the read plan — they are never fetched — while default content on -either side stays one coalesced sequential run. The kept units are -byte-identical to the ones the decrypt hot loop opens. - -### read_plan_phase_parity_is_measured_from_an_unaligned_range_start - -A forensic range does NOT start on an aligned-unit boundary. Its start -LBA comes from a source-packet number — `start_spn * 192` put through -`clip_byte_to_lba` (`mux/resolve.rs`) — and 192-byte packets have no -relationship to the 3-sector aligned unit, so `range_start % 3` is -whatever the disc says. - -That makes `unit_ix = (lba - range_start) / us` load-bearing in both of -its operations, and the existing coverage used a range starting exactly -on the extent's first unit, where several wrong formulas agree with the -right one by arithmetic accident. - -Getting the parity wrong is not a crash. It reads and decrypts the -ALTERNATE variant's half of a forensic segment: the units this disc's -key does not open decrypt to garbage, and the units it does open are -skipped. AACS 2.1 forensic marking is exactly the mechanism that makes -the two halves different, so a phase inversion is silent — it produces -a full-length rip carrying the wrong variant. - -### read_plan_gates_the_last_whole_unit_of_an_extent_not_just_the_remnant - -An extent whose last whole unit is an alternate-phase unit must still -drop it. The tail guard exists for a REMNANT shorter than a unit — -bytes with no following unit to desync — and an extent ending exactly -on a unit boundary has no remnant at all. - -With the guard widened to `remaining <= us`, the final unit of every -extent bypasses the phase gate and is read unconditionally. On a -forensic segment that lands at an extent end, that is one -alternate-variant unit pulled into the rip and decrypted with a key -that does not open it. - -### every_scheme_gives_the_same_verdict_when_no_key_can_be_proven - -Every scheme that CANNOT prove a key answers the same way. - -This is the property `decrypt_span` exists to hold. There used to be -two top-level decrypt paths — one for CSS and clear media, one for -AACS — and each decided its own answer, so they drifted apart in -opposite directions within a single release: CSS descrambled with a -key it had just proven stale, and the AACS path passed an unkeyable -encrypted unit through as ciphertext. Both reported success. - -Asserting one verdict across the schemes is what makes a future -divergence a test failure rather than a silent corruption. A -per-scheme test cannot do that: each would still pass while the two -disagreed. - -CSS is deliberately NOT in this list. Its title key is recovered from -the data, sector by sector, by a heuristic that false-positives — a -crib mismatch whose re-crack fails means the crib was wrong, not that -the key is stale, so the cached key is kept and used. Round 9 folded -CSS in here on the reasoning that "no key" should mean one thing -everywhere; that made real DVDs unrippable, and the real-media gate -caught it. Uniform policy is right for schemes that can PROVE a key -wrong. CSS cannot. - -### an_encrypted_unit_outside_every_key_range_fails_instead_of_passing_through - -An ENCRYPTED unit that falls outside every key-map range must fail, -not pass through as ciphertext. - -"The map has no key here" and "there is nothing to decrypt here" are -different statements, and only the second makes passing the unit -through correct. On a multi-CPS disc an orphan clip — referenced by no -playlist, so in no title extent and therefore in no range — hits the -first and was treated as the second. `extract_tree` then counted those -bytes as GOOD, dropped the `.partial` suffix, and reported `complete: -true`, exit 0: a scrambled file on disk with a clean bill of health. - -A CLEAR unit outside every range is the ordinary case (filesystem and -nav on a whole-disc read) and must still pass through untouched — so -this asserts both directions. - -### mapped_key_selection_is_positional_so_pool_order_matters - -The mapped descramble indexes the committed key pool POSITIONALLY -(`unit_keys[key_idx].1`), so the ORDER of the `Vec<UnitKey>` a -`KeySource` returns is load-bearing — it is NOT "cosmetic, the decrypt -path strips it and tries every key", as -`keysource::resolve_and_apply_traced`'s doc used to claim. Trial-decrypt -was deliberately deleted; nothing here searches the pool. Reordering -the same two keys therefore sends each range to the WRONG key: the -range that decrypted clean now fails the correct-phase `is_clean` net -loudly (or, off a forensic phase, would decrypt a whole span under a -neighbour's key). Pins the corrected doc. - -### decrypt_threads_within_valid_pool_range - -The default (auto) decrypt thread count is always a usable pool size: -at least 1 (a 0-thread rayon pool is invalid) and never above -MAX_THREADS (rayon stack-memory cap). This test reads the resolved -value without mutating the process-global override, so it is safe to -run in parallel with other tests. - -Grounding: `cores.clamp(1, MAX_THREADS)` in the default branch; -`env.min(MAX_THREADS)` in the env branch. -Mutation: change `.clamp(1, MAX_THREADS)` to `.clamp(0, MAX_THREADS)` -on a 0-core probe (unlikely) — more robustly, change the cap to -`MAX_THREADS * 2` -> on a many-core CI box the upper-bound assert can -fail. The lower-bound (>=1) guard is the load-bearing invariant. - -### phase_gate_selects_only_our_parity_of_a_forensic_segment - -The FMTS phase gate picks which half of an interleaved forensic -segment we decrypt. Both the `-` and the `/` in its index arithmetic -survived a mutation run, and getting either wrong silently decrypts the -alternate variant into garbage while reporting success. - -### phase_gate_does_not_panic_on_a_malformed_map - -A malformed key map must not take down a long-running service. A unit -below its own range start, or a zero unit size, are both map bugs — -they must return a defined answer rather than panicking on debug -overflow or dividing by zero. - -Every case here asserts the DEFINED answer, not merely the absence of a -panic. The zero-unit-size case used to be written -`assert!(unit_is_our_phase(100, 30, 0, Phase::Even) || true)`, which -accepts both answers and so pinned nothing at all: the guards could -invert and it would still pass. The answer is knowable — -`saturating_sub` gives 70, `max(1)` makes the divisor 1, unit index 70 -is even — so pin it. -</content> diff --git a/docs/decrypting-sector-source.md b/docs/decrypting-sector-source.md deleted file mode 100644 index 7d5ba1d7..00000000 --- a/docs/decrypting-sector-source.md +++ /dev/null @@ -1,34 +0,0 @@ -# `DecryptingSectorSource` - -## Why this decorator is the single source of truth for decrypt-on-read - -Every decrypt-on-read caller (e.g. `DiscStream`) wraps its source in this -decorator rather than calling the cipher helpers directly. The actual cipher -code lives in `crate::aacs` and `crate::css`; the decorator just calls the -existing `crate::decrypt::decrypt_sectors` helper that drives both of them -in-place after each read. Centralizing the call site means callers can wire -the decorator unconditionally and keep their pipeline shape uniform -regardless of encryption state — for `DecryptKeys::None` discs it is simply -a pass-through, so there is no branch in caller code for "is this disc -encrypted?". - -## `KeyFetch`: why two explicit operations - -`KeyFetch` exposes `unit_keys` and `fmts_indexes` as two separate operations -rather than one generic callback, so the "one base key vs. a whole forensic -set" contract lives in the type instead of a caller guessing at the return -length: - -* `unit_keys` resolves the base Unit Key(s) for a CPS unit from real - encrypted samples drawn from it. The non-forensic path: one key per CPS - unit (the pool grows by whatever it returns). Used by the mux's base / - multi-CPS map resolution and by the sweep/patch recovery decorator. -* `fmts_indexes` resolves the disc's AACS 2.1 forensic index keys from an - index-1 single-phase anchor batch. The source hands back the COMPLETE set - (ordered index 1..N); the caller sizes the forensic map to `len()` and - never assumes a fixed N (32 is all we've seen, but the contract is - "whatever the source returns, >= 1, is all of them"). - -`KeyFetch` is a stateless, shared pair of `Arc<Fn>`, so one instance is built -once and cloned cheaply (two `Arc` bumps) into every read path; it is -`Send + Sync` so it can ride the mux highway's producer thread. diff --git a/docs/deluxe.md b/docs/deluxe.md deleted file mode 100644 index dfa04fce..00000000 --- a/docs/deluxe.md +++ /dev/null @@ -1,227 +0,0 @@ -# Deluxe BD-J framework parser (`src/labels/deluxe.rs`) - -Detected on discs whose `/BDMV/JAR/<x>.jar` contains a `com/bydeluxe/` -directory entry. - -## What this parser reads - -Deluxe-authored discs store stream labels as ordinal references into -enum classes whose names are obfuscated per-disc, so a name-based -match won't work. The label data is instead recovered by matching on -the **shape of each enum's `<clinit>`**, which is framework-stable: - -| Enum | Signature | -|---|---| -| Language | 70 `ldc` operations in `<clinit>`, sequence starts `English, French, Spanish, Dutch, ...` | -| Purpose | 8 ldcs starting `Normal, Commentary, PiP, Trivia, ...` | -| VideoFormat | 7 ldcs starting `HD, HDR10 Plus, HD Dolby, ...` | -| Region | 22 ldcs starting `USA_D1, LIC1, LIC2, LIC3, ...` | -| Studio | 6 ldcs in `<clinit>` | - -Matching on the shape rather than the class name keeps the parser -working across obfuscation variants. Codec strings come from the -standard BD-J `org/bluray/ti/CodingType` enum referenced directly by -the binding constructors (see `StackVal::CodingType`), not from a -Deluxe-internal enum. - -## Implementation phases - -- **Phase A** — master enum identification (`identify_master_enums`). - Walks every `.class`'s `<clinit>` ldc sequence and matches against - the framework-stable fingerprints. Output: `Vec<(label, MasterEnum)>` - with full ordinal → string-value tables. - -- **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. - Heuristic shape; precise threshold may need tuning. - -- **Phase D** — binding-class bytecode decoder (`decode_binding`). - Walks the binding class's `<clinit>` with a tiny symbolic stack - machine. For each `new X / dup / ... / invokespecial X.<init>` - sequence, collects the int values and enum-reference operands - between the `dup` and the constructor call, then emits a - `DecodedStream`. The signal-to-StreamLabel mapping (which arg is - stream index? which is language? audio vs subtitle?) uses a - heuristic — see `interpret_streams` for the mapping rules. - -## Confidence - -`parse` returns `Some(ParseResult::high(labels))` when Phases A -through D produce at least one stream: the master enums matched their -framework-stable fingerprints (a strong ordered-prefix signature), the -binding class decoded, and at least one per-stream binding resolved to a -real (language, purpose, codec) tuple — the schema was fully recovered, -not guessed. `None` when the disc isn't Deluxe-authored or when decoding -produces zero streams (a recognized-but-broken state that the analyzer -still surfaces via `parsers_detected`). - -## Size caps (Phase A candidate pool) - -`MAX_CLINIT_LDC_STRINGS` (cap on `ldc` operands retained per class by -`clinit_ldc_strings`): unlike every other count cap in this crate, the -paired byte cap here cannot be the disc-file size — a `.class` entry -gated only by a `com/bydeluxe/` path prefix deflates from ~100 KB up to -the 64 MiB `MAX_CLASS_BYTES` read ceiling, so ~33M two-byte `ldc` -instructions — one retained `String` each — are reachable from a small -crafted disc. The bound has to be on the decompressed work, so it is -applied here. Headroom: the largest framework-stable enum is `Language` -at 70 values (`FINGERPRINTS`), and no fingerprint matches a count more -than `LDC_COUNT_TOLERANCE` away from its expected size, so anything past -~74 can never identify a master enum. 4096 leaves ~55x headroom over the -largest real enum for framework drift. - -`MAX_CLINIT_LDC_BYTES` (companion byte cap): the count cap alone still -admits 4096 x 64 KiB of `Utf8` (a JVMS `CONSTANT_Utf8_info` length is a -u16), i.e. ~268 MB per class from repeated `ldc` of one huge constant. -Headroom: master-enum values are short display names ("English", -"HDR10 Plus", "USA_D1") well under 32 bytes, so a real Language enum -retains ~1 KB. 256 KiB admits 4096 values averaging 64 bytes each. - -`MAX_CANDIDATE_TOTAL_BYTES` (aggregate companion, bounds retention across -ALL classes at once): `identify_master_enums` holds every class's -retained strings in one map simultaneously, so the per-class cap alone -still admits `classes x 256 KiB`: a 64 MiB jar of minimal `.class` -entries reaches tens of GiB. This is the same bound one level up. -Headroom: the five `FINGERPRINTS` enums together hold ~113 short values -(~1.2 KB). Every other class in a real BD-J jar contributes only -whatever string constants its own `<clinit>` loads — resource paths, -config keys — so a large authored jar lands in the low hundreds of KB. -16 MiB leaves ~40x headroom over a deliberately generous 400 KB estimate -for real media. - -`MAX_CANDIDATE_CLASSES` (entry-count companion to -`MAX_CANDIDATE_TOTAL_BYTES`): the byte budget alone still admits ~16M -map entries when every class retains a single one-byte `ldc`, and the -per-entry `HashMap` + `String` overhead is not counted by that budget. -Headroom: a large retail BD-J title ships on the order of 1-3k classes, -and only those with a non-empty `<clinit>` ldc sequence become -candidates. 65536 leaves >20x headroom over the class count of any real -jar. - -## Internal helpers - -- `clinit_enum_field_names`: collects the static-field names an enum - class's `<clinit>` stores its own instances into, in declaration order - — the ordinal -> field-name mapping that mirrors `clinit_ldc_strings`'s - ordinal -> value mapping. An obfuscated Deluxe enum constant compiles - to `new E; dup; ldc "Value"; invokespecial E.<init>(…); putstatic - E.<field>`, so the `putstatic` whose owning class AND field descriptor - are both this class's own type names the field for that ordinal. The - binding class then references that constant as `getstatic E.<field>`; - without this mapping `MasterEnumTable` cannot turn the obfuscated field - name back into an ordinal. Bounded by `MAX_CLINIT_LDC_STRINGS` like the - value walk. - -- `clinit_ldc_strings`: walks `<clinit>` and collects every `ldc`/`ldc_w` - operand that resolves to a `String` or `Utf8` constant, in declaration - order; `None` if the class has no `<clinit>`. Collection stops at - `MAX_CLINIT_LDC_STRINGS` operands or `MAX_CLINIT_LDC_BYTES` of retained - text, whichever comes first: the walk is driven by the decompressed - class, so it isn't bounded by the disc-file size cap the way the rest - of this module's counts are. Truncation cannot lose a real match — a - truncated sequence is far longer than any `FINGERPRINTS` entry's - `expected_count + LDC_COUNT_TOLERANCE`, so it would have been rejected - on count anyway. - -- `find_binding_classes`: identifies all binding-class candidates by - getstatic-count to the master enums, using `MIN_GETSTATIC = 4` — the - binding class on a typical disc has 50+ such getstatic references (one - per slot x arity); the low floor lets a small disc with few streams - still qualify, while still filtering out classes that just reference - the language enum once for a config string. Some Deluxe discs split - the per-stream table across two binding classes (one for audio, one - for subtitle — the audio class commonly has the most getstatic refs, - the subtitle class somewhat fewer, both sharing the master Language + - Purpose enums), so this returns top-K candidates ordered by descending - getstatic count, filtered to a minimum concentration of references. - -`MAX_CONSTRUCTIONS` (Phase D, cap on `Construction`s retained from a -binding `<clinit>` walk): one entry is appended per matched `new X / dup -/ invokespecial X.<init>` with no other bound — a `.class` gated only by -a `com/bydeluxe/` path prefix deflates to the 64 MiB `MAX_CLASS_BYTES` -ceiling, and the shortest matching sequence is a handful of bytes, so -millions of `Construction`s (each a `String` plus an arg `Vec`) are -reachable from a small crafted disc (~1 GiB). The same cap bounds the -per-class union in `decode_binding_class` (a crafted class may repeat -`<clinit>`, which JVMS §4.6 forbids but this reader tolerates) and the -cross-class union in `parse`, so the whole phase retains at most this -many. Headroom: the BD `STN_table` (BDAV stream-entry counts) admits at -most 32 primary audio and 32 PG streams per playlist, and a Deluxe -binding table covers the disc's playlists — low hundreds of entries on -the largest retail titles. 4096 leaves >20x headroom. - -`Decoder::push` (symbolic-stack push during binding `<clinit>` walking): -honours the Code attribute's declared `max_stack`. JVMS 4.7.3 requires -that a method's operand stack never exceed `max_stack` at any point, so -a push past it can only come from bytecode that would fail JVM -verification. Dropping it costs nothing on real bytecode and bounds the -decoder: a `.class` gated only by a `com/bydeluxe/` path prefix deflates -from ~100 KB to the 64 MiB `MAX_CLASS_BYTES` ceiling, i.e. ~67M -single-byte `iconst_0` (~2 GiB of `StackVal`) on an unbounded `Vec`. -Headroom: `max_stack` is exactly what javac computed for the real -binding `<clinit>`, so no real construction can be clipped. The symbolic -stack counts `long`/`double` as one slot where the JVM counts two, so -its depth is never greater than the verified depth. - -## interpret_streams: Constructions -> StreamLabels - -Converts the per-construction tuples from Phase D into `StreamLabel`s. -Two binding-constructor shapes are handled: - -- 5-arg: `BindingType.<init>(I, Lang;, Lpurpose;, I, LCodingType;)V` -- 4-arg: `BindingType.<init>(I, Lang;, Lpurpose;, LCodingType;)V` - -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. -- Neither, and its binding type never yielded a stream -> not a - stream (skip). See `slot_kind` for why the binding type is - consulted rather than the language alone. - -`slot_kinds`: which stream list each binding type enumerates, learned -from the constructions that DID resolve a language. A `<clinit>` walk -emits a `Construction` for every `new X; ...; invokespecial X.<init>` -it sees, so the list mixes real stream bindings with whatever else the -class initializer builds. `binding_type` is the constructed class name, -which is how the two are told apart: the stream bindings all share one -class (Deluxe splits audio and subtitle across two), and that class is -identifiable from the slots that resolved. A binding type that resolved -as both kinds is left out — with no consistent answer, guessing a list -to advance would be worse than not advancing. - -`slot_kind`: the stream list an unresolved construction occupies a slot -in, or `None` when it is not a stream binding. A -`org.bluray.ti.CodingType` argument is decisive on its own: nothing but -an audio stream binding is handed one. Otherwise falls back to what the -binding type's resolved siblings showed (`slot_kinds`). - -## Confirmed studio variants - -- **Universal** (`studio="uni"`): Language enum `pd` (65 values, - `English, French, Spanish, Dutch, …`), Purpose enum `lp` - (`Normal, Commentary, PiP, Trivia, Descriptive, Score`), audio binding - `np.<init>(I, Lpd;, Llp;, Lorg/bluray/ti/CodingType;)`, subtitle binding - `wb.<init>(I, Lpd;, Llp;, Lmi;)`, all built in one binding class's - `<clinit>` alongside a title-wrapper object whose constructor takes the - per-stream arrays (filtered out by the array-parameter guard in the - decoder). SDH/RNIB is encoded as a distinct Language VALUE - ("English SDH", "English RNIB"), recovered into the qualifier from the - name. Grounded on fixtures captured from a real Universal Blu-ray release. -- **Disney/Warner**: 70-value Language enum, same binding shape; the - original corpus this parser was written against. diff --git a/docs/demux-sink.md b/docs/demux-sink.md deleted file mode 100644 index e9dbe349..00000000 --- a/docs/demux-sink.md +++ /dev/null @@ -1,77 +0,0 @@ -# `demux://` sink — per-codec ES output - -The `demux://` sink is a write-only `crate::pes::Stream`. Instead of muxing -the per-track `PesFrame` stream into a single container (as `MkvStream` -does), it routes each frame's payload to the file for `frame.track`, -post-processing where the internal codec `Frame` form differs from the -standalone on-disk ES form: - -- **HEVC / H.264**: the codec parsers emit hvcC/avcC-style 4-byte - length-prefixed NALs (start codes stripped) and carry the parameter sets - out-of-band in `codec_private`. A standalone `.hevc`/`.h264` needs Annex-B - framing with the parameter sets prepended — see `AnnexBWriter`. -- **PGS**: the parser collapses a display/clear PCS pair into one - duration-bearing `Frame` with the raw segment payload but no `PG` magic / - timestamp header. A `.sup` needs the HDMV segment framing rebuilt — see - `PgsSupWriter`. -- **VobSub**: the `.sub` is the raw SPU stream; the `.idx` sidecar is - synthesized from per-SPU PTS + byte offsets — see `VobSubWriter`. - -Every other codec writes `frame.data` verbatim (`PassthroughWriter`). - -The sink does NOT touch the MKV mux path; it is purely additive. - -## `PgsSupWriter`: rebuilding HDMV segment framing - -The parser hands the demux sink the concatenated PGS segments of a display -set in `frame.data` (segment_type + segment_size + payload, repeated), with -no `PG` magic and no PTS/DTS. A `.sup` prefixes each segment with a 13-byte -header: `0x50 0x47` ("PG") | PTS u32 BE | DTS u32 BE | (segment_type|size -already present in the payload). Original clear sets are emitted verbatim. -For duration-only input (older MKVs, or an EOF fallback), the writer holds -a synthetic clear for `pts + duration`. A real clear or replacement PCS at -or before that deadline cancels the synthetic one. Otherwise it is emitted -before the next later frame, or at finish. Clear and continuation frames do -not themselves schedule further clears. - -## `synthetic_clear_display_set`: synthesizing the clear PCS - -Builds a synthetic "clear" display set: an empty PCS (0 composition objects) -followed by an END segment. The parser folds the original clear/end PCS -pair's wipe time into the display frame's `duration_ns` and drops the clear -bytes, so a faithful `.sup` re-emits one here at `display_pts + duration`; -without it every subtitle lingers to EOF. `width`/`height` are carried from -the display set's PCS so the clear PCS advertises the same video geometry — -they don't affect the wipe but keep the segment well-formed. Returned bytes -are concatenated `type(1)+size(2 BE)+payload` segments, the same shape -`emit_segments` consumes. - -## Test: `epoch_driver_follows_ref_video_not_track_zero` - -Regression for the hardcoded `frame.track == 0` epoch driver: on an M2TS/PMT -title the PMT can list an AUDIO ES before the VIDEO ES, so the video lands -at stream index 1. The sink already resolves the video reference -dynamically (`ref_video_track`); the epoch driver must use that SAME -reference, not the literal 0. In the test, track 0 is audio and track 1 is -video; a video-only clip boundary (track 1) must open a new epoch (bump -`offset_ns`). With the bug (only `frame.track == 0` drives epochs) the -video back-jump would be treated as a passive rider and `offset_ns` would -stay 0 — corrupting every track's rebased timeline. - -## Test: `a_demux_export_the_seam_plan_emptied_fails` - -Audit finding: this sink builds its timeline with -`TimelineContinuity::with_clips` but no test ever gave it a title with -usable PlayItem marks, so `frames_mapped` and `dropped_total()` were always -zero and BOTH gates in `finish` were unreachable. Deleting either left the -whole suite green while a `demux://` export of a seamless-branching title -wrote zero-byte track files beside a populated chapters document, at exit 0. - -## `sanitize`: path-hostile filename characters - -Control characters are replaced, NUL above all. Every string this touches is -disc bytes, and a language code is three raw STN bytes run through -`from_utf8_lossy` with no validation — `00 00 00` is the ordinary -"undefined" encoding on real Blu-rays. A NUL in a path aborts -`File::create` with `InvalidInput`, which took the whole demux export down -before a single track file was opened. diff --git a/docs/demux-thread.md b/docs/demux-thread.md deleted file mode 100644 index 934227a6..00000000 --- a/docs/demux-thread.md +++ /dev/null @@ -1,65 +0,0 @@ -# `DemuxThread` - -## Why a second worker thread - -With `crate::sector::PrefetchedSectorSource` alone, read+decrypt already -runs on a producer thread; the *consumer* (main) thread still serialises -`ts_demuxer.feed` (M2TS parsing) with the codec parsers. Profiling showed -feed at ~37% and codec parse at ~44% of consumer wall time — i.e. feed is -heavy enough that pipelining it with parse pays for itself. - -Splitting them: feed runs in `DemuxThread`; the consumer thread receives -`Vec<PesPacket>` batches and runs codec parse + frame emission only. Total -throughput becomes `1/max(feed, parse)` instead of `1/(feed + parse)`. - -## Lifecycle - -`DemuxThread::spawn_zero_copy` consumes the prefetch channels and the -demuxer state, returning a handle plus a `Receiver<DemuxBatch>`. Dropping -the handle closes the channel which signals the worker to exit; the join -in `Drop::drop` blocks until the worker observes channel closure and -returns (no timeout — a wedged downstream would block the drop until it -releases the channel). - -## `spawn_zero_copy` rationale - -Instead of taking a `SectorSource` and memcpy-ing through its -`read_sectors` API, this constructor consumes the prefetch channels -directly: filled buffers come in via `prefetch_rx`, the demux thread -feeds them, then returns them to `recycle_tx` for the producer to -re-fill. Eliminates the 16 MiB memcpy per batch that the SectorSource -adapter incurred (and, with the producer-side recycling pool, also -eliminates the per-batch heap alloc / cross-thread free that was costing -40%+ of demux-thread time before). - -## Test: empty-batch disconnect fix (`worker_exits_promptly_on_consumer_drop_during_empty_batches`) - -Regression: worker must detect consumer disconnect even when every demux -batch is empty (no matching PIDs / null packets). - -Before the fix, `tx.send()` was never called for empty batches so the -worker never observed the consumer drop — it would spin through ALL -remaining extents before exiting, causing `DemuxThread::drop`'s `join()` -to block for minutes on a mostly-untracked disc region. - -The watchdog: if the worker doesn't exit within 1s of the consumer drop -the test fails (rather than hanging forever as the bug would). - -## Test: spawn-failure drop-order (`channels_disconnected_before_producer_join_on_spawn_failure`) - -Regression: on thread-spawn failure the channels must be dropped BEFORE -the producer shell so the upstream producer observes disconnection and -exits, allowing `join()` to complete without hanging. - -A true EAGAIN/pids-limit spawn failure cannot be reliably forced in a -unit test without root or ulimit co-operation, so the test exercises the -drop-order contract directly: a mock shell that panics if `join()` is -called while either channel end is still open. - -The test constructs a `(prefetch_tx, prefetch_rx)` pair where the tx side -is held by a sentinel that stays alive as long as either channel end is -open, then asserts that the sentinel is gone by the time -`producer_shell`'s join logic would run. Because a real spawn failure -can't be forced, the test instead verifies the helper logic in isolation: -drop `prefetch_rx` and `recycle_tx` first, then observe the producer-side -sender is disconnected, which is the property the fix relies on. diff --git a/docs/diag.md b/docs/diag.md deleted file mode 100644 index 649a4bf2..00000000 --- a/docs/diag.md +++ /dev/null @@ -1,65 +0,0 @@ -# diag.rs — scan diagnostics rationale - -## Why this module exists - -A bug report log must be self-diagnosing: everything needed to explain *why* -freemkv made the choices it did at scan must be in the log, in a compact, -machine-parseable form. `diag.rs` emits one terse line per row (title, cell, -stream, decision) under the `tracing` target `freemkv::diag`, which the CLI -routes to `log.txt` when `--log-level 3` (debug) is set. - -## Format conventions (stable, greppable) - -- Every line is prefixed by a `tag=` so a log scraper can filter (`disc`, - `title`, `dvd.cell`, `dvd.vattr`, `dvd.aattr`, `bd.clip`, `bd.mark`, `aacs`, - `stream`, `decision`). -- Raw bytes are shown as `0xNN` next to their decode so a wrong decode is - obvious against the raw value. -- This module only READS already-parsed scan state — it never re-reads the - disc and never mutates anything. - -## DVD per-cell table vs. Disc-level dump - -The DVD per-cell table (with the raw cell-category byte) is emitted from the -IFO scan itself (`dump_dvd_cells`), because the per-cell `ifo::DvdCell` detail -is lowered away before the `Disc` is built. The `Disc`-level dump -(`dump_disc`) covers everything that survives lowering: titles, streams, the -picked main feature, and AACS state. - -## `dump_dvd_substream_probe` - -Emits the ACTUAL per-physical-sub-stream AC-3 channel counts read off the VOB -during the mux-time sub-stream probe (the Silence-of-the-Lambs wrong-stream -fix). This is the ground truth the IFO nibble is compared against: each row is -`sub_id=0x8x channels=N` for a physical `private_stream_1` AC-3 sub-stream -whose first frame was decoded. An empty probe (scrambled / unreadable / short -VOB) logs a single `probed=0` line so the absence is explicit in a bug log. - -Self-sufficiency: with `tag=dvd.aattr` (the IFO's declared sub_id + claimed -channels) and these `tag=dvd.substream` rows (the physical reality), a bug log -alone shows whether the ordinal `0x80` actually carries the declared channel -layout — no disc needed to diagnose a wrong-substream rip. - -## `dump_mkv_track` - -Emits the MKV `TrackEntry` elements the muxer is about to WRITE for one track -— the Windows-fps-class metadata (FlagInterlaced, FieldOrder, -DefaultDuration, DefaultDecodedFieldDuration, Display dims) plus the -codecPrivate as hex. With this row a bug log alone is enough to verify why -Windows Explorer reports a given frame rate for an interlaced SD track: the -container values that drive its fps derivation are all present, no disc and -no MediaInfo needed. - -`track_number` is the 1-based MKV track number; `track` is the built -`crate::mux::mkv::MkvTrack` whose fields map one-to-one onto the emitted -elements (see `MkvMuxer::new`). - -## `main_feature_reason` - -The `reason=` token on the main-feature decision row is DERIVED from -`Disc::CANONICAL_TITLE_ORDER_KEYS`, which lives beside the comparator that -actually implements them — never restated here. The previous hand-written -copy drifted (it advertised a `fewest-clips` key the comparator had replaced -with largest-physical-size), which made the self-diagnosing log explain the -pick with a rule the code does not apply. A diagnostic that disagrees with the -decision it documents is worse than no diagnostic. diff --git a/docs/dirimage-tests.md b/docs/dirimage-tests.md deleted file mode 100644 index e38646ec..00000000 --- a/docs/dirimage-tests.md +++ /dev/null @@ -1,132 +0,0 @@ -# Rationale for `src/dirimage/tests.rs` - -Longer-form notes for individual tests, referenced from `// See docs/dirimage-tests.md` -pointers in the source so the inline comments stay within the comment-guard caps. - -## `Scratch::unique_path` - -The path a scratch dir takes is kept separate from directory creation so its -uniqueness is testable without a syscall between draws. A monotonic counter, -NOT a timestamp, is what guarantees it: `SystemTime::now()` resolves to only -a MICROSECOND on macOS, so two of the seven parallel tests that share the -"bdmv" tag routinely read the same value within one microsecond, collide on -the same directory, and the first to finish `remove_dir_all`s it out from -under the others' reads — an intermittent `read_sectors` ENOENT. The counter -is unique regardless of clock granularity; pairing it with the pid keeps it -unique across test-binary processes. - -## `scratch_paths_are_unique_even_at_clock_resolution` - -Every `Scratch` must own a DISTINCT directory. Seven tests share the "bdmv" -tag and run in parallel; if two land the same path, the first to drop -`remove_dir_all`s it out from under the other's reads, which surfaced as an -intermittent `read_sectors` failure. Regression: with the old -`SystemTime::now()` name this is RED, because macOS's clock advances only -per microsecond, so a tight loop of pure draws — no `create_dir_all` syscall -between them to nudge the clock, mirroring the real cross-thread collision — -hands back the same value many times over. The counter-based name is unique -regardless of clock granularity. - -## `a_directory_spanning_many_blocks_reads_back_whole` - -A directory with enough children that its FID list spans several 2048-byte -blocks, read back through the production parser. - -This is the case the block-alignment question turns on: FIDs are packed -contiguously and a descriptor may straddle a block boundary, because -`read_directory` (`udf.rs:1312`) walks the directory extent as one flat byte -run and BREAKS at the first non-257 tag. Padding each block would truncate -the directory at the first pad. 200 entries also exceeds the 16-handle LRU -several times over, so it exercises handle eviction on the read path. - -## `a_file_below_video_ts_is_placed_not_just_declared` - -A file inside a SUBDIRECTORY of `VIDEO_TS` must still have its data placed. - -Audit finding. The DVD branch places only the files directly inside -`VIDEO_TS`, because only those carry the IFO-relative constraints — and the -follow-up loop skipped that directory entirely, so anything one level deeper -got a File Entry declaring the file's real size with no extents behind it. -It appeared in the tree at full length and read back as nothing, at exit 0. -The identical folder under `BDMV/` was always placed correctly, which is -what made the gap easy to miss. - -## `a_vob_offset_past_the_image_cap_is_refused` - -A VOBS offset far past the content must be REFUSED, not honoured. - -Audit finding. The planner honours a title set's declared VOBS offset -because honouring it is what makes a real backup readable, and nothing -bounded it: a regenerated `.BUP`, a rewritten IFO or a hand-assembled folder -naming a huge offset grew the image to wherever it pointed. A `u32` sector -count reaches roughly 8.8 TB, and writing that to an `iso://` destination -would fill a disk with zeros before anything noticed. - -The companion (`an_oversized_vob_offset_leaves_a_gap_rather_than_failing`) -pins that a MODEST oversize is still honoured as a gap, so this cap refuses -only what it must. - -## `playable_bdmv` - -A BD folder that really enumerates a title, so the AACS content probe has -an extent to sample. - -The `.m2ts` is built as real 192-byte BD source packets, because that is -what the probe judges: byte 0 carries the AACS Copy Permission Indicator in -its top two bits, and byte 4 is the MPEG-TS sync. `scrambled` sets the CPI -and withholds the sync — "flagged and not structurally clean", which is -exactly `aacs_unit_needs_decrypt`. An all-zero payload would prove nothing -either way: `is_clean_ts` skips zero payloads as padding. - -## `both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory` - -The two doors must AGREE. Same folder, same verdict, same extents. - -Stated as a differential rather than a bare `is_ok()` so it cannot go -vacuous: if the fixture ever stops producing an AACS state, a one-sided -`Ok` assertion would still pass while guarding nothing, whereas "both doors -see the same title" is the invariant the shared function actually exists to -hold. - -## `a_scrambled_folder_is_refused_through_the_dir_url_door_too` - -The other verdict, through the same door: a folder whose content units are -really scrambled is refused with the TYPED code, not muxed into garbage. - -This is the load-bearing half. `E_DIR_IMAGE_ENCRYPTED` is produced at -exactly one site in the crate (`session::apply_folder_encryption_verdict`), -reachable from here only through the `is_folder` argument — so this test -cannot pass if that argument is dropped, whatever else changes. - -## `write_and_mount_externally` - -Write a synthesized image to a real file and ask the OS to mount it. - -This is the only check here that is not circular: `read_filesystem` shares -every assumption with the encoder, an operating system's UDF driver shares -none of them. Ignored by default because it shells out to `hdiutil` and -needs a host that can attach an image; run with -`cargo test -- --ignored write_and_mount_externally --nocapture`. - -## `dvd_placement_invariant_on_a_real_folder` - -Diagnostic (opt-in): verify the DVD placement invariant for every title set -in a REAL folder. `ifo.rs` derives a title's extents as -`file_start_lba(VTS_nn_0.IFO) + vtstt_vobs`, so that sum must land exactly on -`VTS_nn_1.VOB` or the mux reads the wrong sectors for that title. - -Run with: `FMKV_DVD_FOLDER=/path/to/tree cargo test --lib -dvd_placement_invariant_on_a_real_folder -- --ignored --nocapture` - -## `a_multi_clip_playlist_produces_feed_spans_that_tile_the_feed` - -`Clip::feed_span` is the INPUT to the whole provenance feature — it is what -tells the muxer which clip a byte offset belongs to — and it is produced in -exactly one place, `disc/bluray.rs`. Every `SeamPlan` test synthesizes spans -by hand, so the consumer is thoroughly tested against fixtures written from -the same assumptions as the producer, and nothing checks the producer at all. - -That is the shape of the original defect: the placement logic was tested and -correct, and the thing feeding it was wrong. `SeamPlan` only trusts spans -that TILE the feed contiguously from zero, so this asserts exactly that, -against the real scanner reading a real synthesized filesystem. diff --git a/docs/dirimage.md b/docs/dirimage.md deleted file mode 100644 index ebe38f01..00000000 --- a/docs/dirimage.md +++ /dev/null @@ -1,185 +0,0 @@ -# `dirimage` — `dir://` as an image-level source - -`dirimage` synthesizes a real, minimal, valid UDF 1.02 volume over a host -folder (a DVD `VIDEO_TS/` or Blu-ray `BDMV/` extracted by a MakeMKV-style -backup). Nothing is emulated: - -* **Metadata sectors** (anchors, the volume descriptor sequences, the File - Set Descriptor, every File Entry, every directory's FID list) are encoded - into RAM by `encode` — a few MiB even for a large Blu-ray. -* **Data sectors** are not materialized at all. Each one maps to a byte - range of a real file, read on demand. - -So `udf::read_filesystem` parses this image by exactly the same code path it -parses a real disc with, and every consumer above it is unchanged. The cost -is that a single-partition synthetic volume never exercises the UDF 2.50 -Metadata Partition path (`udf.rs:946-991`) that every real BD-ROM uses — -this module's tests do not cover that block and must not be read as if they -did. - -## What this module deliberately does NOT do - -* **3D / SSIF** — rejected up front (`Error::DirImageSsifUnsupported`). An - SSIF aliases the same sectors as its base and dependent `.m2ts`; the - planner allocates disjoint extents, so a 3D folder would produce silently - wrong output. -* **HD-DVD `HVDVD_TS/`** — no title enumerator constraint is modelled. -* **Encrypted folders** — a folder whose content is still AACS-scrambled is - rejected by the caller-side probe, not decrypted here. - -## `layout.rs` — rationale detail - -Full detail for the comments trimmed in `src/dirimage/layout.rs`, in file order. - -**Module overview.** Two phases, kept apart because they fail for different -reasons: walk rejects what the image model cannot represent (3D SSIF, an -unrecognized tree, a case collision inside one directory); assign places -metadata first (File Set Descriptor, one File Entry per node, then the -directory FID lists), then file data. Data placement is the part with real -constraints. For a Blu-ray there are none — `.mpls`/`.clpi` address clips by -name, never by LBA — so files are packed sequentially. For a DVD the IFOs -record VOB positions as sector offsets from the IFO's own start, and `ifo.rs` -re-derives every title extent from them, so the placement must reproduce -those offsets exactly or the rip reads the wrong sectors (`place_video_ts`). - -**`DATA_FLOOR`.** Well clear of the volume-space descriptors, and — the -load-bearing part — far from zero: `disc/bluray.rs:137` drops any clip extent -whose LBA is 0, so a file that landed at block 0 would vanish from the title -with no error. - -**`MAX_AD_BYTES`.** The AD length field is 30 bits (the top two are the -ECMA-167 4/14.14.1.1 extent type), so 0x3FFF_FFFF is the arithmetic ceiling — -but a non-final extent must be a whole number of blocks, so the usable -ceiling is the largest multiple of 2048 below it. Files larger than this are -split across several ADs; `udf.rs` reads them back as a multi-extent file, -the same shape a dual-layer disc produces. - -**`MAX_DEPTH`.** Matches `udf.rs`'s `MAX_DIR_DEPTH`: anything deeper would be -recorded but never descended into, so the files under it would be invisible -to every consumer. Refuse instead of silently dropping. - -**`MAX_CS0_NAME_BYTES`.** The FID's name-length field is one byte, so 255 is -the ceiling; 254 leaves the encoder no way to produce a value that wraps to -zero. - -**`MAX_SUBDIRS`.** A directory File Entry's link count is `u16` and counts -one per child directory plus one for its own entry in the parent, so the -last usable value is `u16::MAX - 1`. Lowered under `cfg(test)` ONLY so the -guard can actually be executed: building a folder with 65534 subdirectories -to reach the real cap is not a test anyone can run, so an earlier test -asserted arithmetic about the constant instead and would have passed with -the guard deleted. The production value is unchanged. - -**Module-scope link-count assert.** Must live at module scope, not inside -`mod tests`: it previously sat inside `#[cfg(test)] mod tests` while also -carrying its own `#[cfg(not(test))]`, so it was compiled in NO configuration -and could never fire — exactly the dead gate the test above it was written -to replace. - -**`MAX_IMAGE_SECTORS`.** A DVD title set records where its VOBS begins as an -offset in its own IFO, read verbatim out of a file in the folder. A -regenerated `.BUP`, a tool that rewrote an IFO, or a hand-assembled folder -can therefore name an offset far beyond the content — and the planner -honours it, because honouring it is what makes a real backup readable. -Without a ceiling the image grows to wherever that offset points: a `u32` -sector count reaches ~8.8 TB, and writing one to an `iso://` destination -would fill a disk with zeros before anything noticed. 128 GiB clears the -largest real medium (BD-100) with room to spare, so a genuine disc folder -never meets it. - -**`MAX_META_BYTES`.** Every node costs a 2 KiB File Entry sector held for the -life of the image, so the entry cap alone permits ~205 MB of metadata for -content of no size at all — and the mux holds two of these at once while -probing. The module documents a budget of "a few MiB even for a large -Blu-ray"; this is what enforces it rather than merely asserting it. - -**`is_excluded`.** Dot-files are host artefacts, never disc content: macOS -sprays `.DS_Store` and `._*` resource forks through any folder a Finder -window has touched, and including them would put files on the "disc" that -were never on the disc. `.partial` is freemkv's own in-flight extraction -suffix (see `disc/extract.rs`) — picking one up would mean planning an -extent over a file that is still being written. - -**`read_head`.** Reads the first `n` bytes of an IFO so its placement -offsets can be resolved. Errors propagate: returning an empty buffer instead -would send every offset through `unwrap_or(0)`, recording NO placement -constraint, and a VOB placed without its constraint yields an image that -reads at the wrong offset with nothing reported. An IFO that cannot be read -is a folder that cannot be planned. - -**`place_video_ts`.** DVD-Video records VOB positions INSIDE the IFOs, as -sector offsets from the IFO file's own first sector: `VIDEO_TS.IFO` + 0xC0 -(`VMGM_VOBS`) → `VIDEO_TS.VOB`; `VTS_nn_0.IFO` + 0xC0 (`vtsm_vobs`) → -`VTS_nn_0.VOB` (the VTS menu); `VTS_nn_0.IFO` + 0xC4 (`vtstt_vobs`) → -`VTS_nn_1.VOB` (the title stream). `ifo.rs:554-556` re-derives every title -extent as `file_start_lba(IFO) + vtstt_vobs + cell.first_sector`, so only the -third of those is load-bearing for freemkv itself — but the other two are -load-bearing for DVD PLAYERS, which read the menus freemkv ignores. All -three are honoured. `VTS_nn_1.VOB … VTS_nn_9.VOB` are one logical stream -split at the 1 GB file-size limit, and the cell sector addresses run -continuously across the split, so they are placed back-to-back with no gap; -laying the group out in its canonical on-disc order gives exactly that for -free, so the constraint becomes a CHECK, not a search. The check can fail — -a regenerated `.BUP`, a tool that rewrote an IFO, a folder assembled by hand -— and when it does the required position lies below the end of the file -that must precede it. There is no placement that satisfies it, so it is a -typed error naming the file rather than a silent misplacement. - -**`reject_ssif`.** `disc/bluray.rs:124-130` probes -`/BDMV/STREAM/SSIF/{clip}.ssif` and sets `is_3d` the moment one resolves — -unconditionally, before any capability check. On a real 3D disc the `.ssif` -and the base/dependent `.m2ts` files ALIAS the same sectors (the SSIF -interleave IS the two m2ts streams), which this planner cannot express: it -would allocate three disjoint copies, so the clip's extents and the m2ts's -extents would disagree and the rip would read the wrong bytes at exit 0. -Reject the folder instead. - -**`plan` — capacity, and what it changes.** `total_sectors` becomes the -`Disc`'s `capacity_sectors` / `capacity_bytes`, and `capacity_bytes` is not -inert: `canonical_title_order`'s oversize gate (`disc/mod.rs:2039-2040`, body -`:2209-2210`) demotes any title whose `size_bytes` exceeds it, which decides -which title sorts first and therefore what `-t 1` selects. The capacity -reported here is **the synthesized image's own size** — what an ISO built -from this folder would report — and nothing else. It is NOT padded up to a -media tier (BD25/BD50/…): that was considered and does not work, since a -folder does not record the tier of the disc it came from, so a 22 GB folder -off a BD50 would pad to BD25 and diverge anyway, and the padding would -inflate any pre-sized output written from the image. The consequence, stated -plainly: **for a folder that is missing files the source disc had — a -selective MakeMKV backup being the normal case — the capacity is smaller -than the source disc's, the oversize gate is therefore stricter, and a -borderline "play-all" composite title that the ISO kept can be demoted here. -`-t 1` can select a different title from `dir://FOLDER` than from an -`iso://` of the same disc.** For a complete folder the two agree, because -the capacities agree. This is chosen over the alternatives because it is the -only one that is self-consistent: `dir://X` and an `iso://` built from `X` -describe the same image and must answer the same way. - -**Tests.** -* `a_file_past_the_ad_ceiling_splits_on_a_block_boundary` — a file above the - 30-bit AD ceiling must split into MULTIPLE descriptors, and every - non-final one must be a whole number of blocks, or the next extent's bytes - start mid-sector and the file reassembles wrong. Kills a mutant that emits - one oversized AD (whose length would collide with the extent-TYPE bits - `udf.rs:642` reads). -* `two_names_the_reader_cannot_tell_apart_are_refused` — audit finding: the - uniqueness check compared raw host names, while `parse_udf_name` trims - whitespace and drops code units `char::from_u32` rejects. So - " 00000.m2ts" and "00000.m2ts" were two distinct entries at plan time and - ONE name at read time; `find`/`read_file` take the first match, so a title - resolved to the wrong file's extents and muxed the wrong bytes at exit 0. - Calls `plan` on a real folder and fails if the round-trip key is reverted - to comparing host names. -* `an_over_long_name_is_refused_by_the_planner` — audit finding: the length - was narrowed with `as u8`, so a 255-byte ASCII name (POSIX NAME_MAX, legal - on ext4/APFS/NTFS) encoded to 256 bytes with the CS0 compression byte and - wrote a length of ZERO, making every later entry in that directory read - from the wrong offset. An earlier version of this test asserted arithmetic - about the constants and never called `plan`, so it would have passed with - the guard deleted. -* `the_subdir_cap_refuses_a_folder_with_too_many_subdirectories` — pins the - arithmetic relationship the guard relies on and executes the guard rather - than restating the constant: link count (child dirs + 1) is 16 bits, so - exceeding it wraps and the image lies about its own directory structure. - Creating 65,535 directories in a test is not reasonable, so this does NOT - exercise `walk`. diff --git a/docs/disc-mod.md b/docs/disc-mod.md deleted file mode 100644 index 64d6caf8..00000000 --- a/docs/disc-mod.md +++ /dev/null @@ -1,492 +0,0 @@ -# src/disc/mod.rs — internal notes - -Overflow rationale for internal (non-`pub`) items in `src/disc/mod.rs`, -relocated here by the comment-guard so the in-file comment can stay short. -Each section is pointed to by a one-line `// See docs/disc-mod.md — <topic>` -comment at the corresponding call site. - -## aacs_dir_present - -THE ONE definition of "is this disc structurally AACS-encrypted". Both -`Disc::identify` (fast path, filesystem only) and `Disc::scan_with` (full -scan) need this, and both used to spell out the same two `find_dir` calls by -hand. They agreed today; nothing made them agree tomorrow — adding a third -AACS location, or excluding an empty placeholder directory, to one copy and -not the other would silently desync the fast identify from the full scan (the -same disc reported encrypted by one and clear by the other). - -This is STRUCTURAL, not cryptographic: it says the tree looks like an -encrypted disc, not that any sector actually is. A folder copied verbatim -from a decrypted disc keeps its `AACS/` and answers true here (see -`session::scan_dir`, which corrects for exactly that). - -## correct_truehd_channels - -Corrects a title's TrueHD audio-stream metadata by probing the first -decrypted access units — channel count, real sample rate, and Atmos detection -in a single major-sync read. The MPLS descriptors declare the BASE layout -(often 5.1, or a container-guessed rate) even for a 7.1/Atmos TrueHD track; -the truth is in the MLP major sync. `reader` must yield DECRYPTED sectors -(the m2ts is AACS-encrypted, so this can only run at mux time, not scan). -Reads a bounded window of the title's first extent. - -Corrections, each individually guarded so a malformed field never writes a -wrong header: -- **Channels**: from the presentation channel masks (as before). -- **Sample rate**: from the whitelisted rate nibble; left untouched on an - unknown rate or no major sync. -- **Atmos**: when a 4th substream is detected AND the stream still carries - the basic descriptor label, the label is promoted to the Atmos form; - richer editorial labels (e.g. an existing "Dolby Atmos") are left intact. - -## Resolution::pixels - -`Unknown` deliberately does NOT fabricate a plausible 1920x1080. This is the -same trap already closed on `AudioChannels::count` (which used to return 6) -and `SampleRate::hz` (48000.0): a plausible wrong answer is indistinguishable -from a real one at every call site, so a caller can forget to check the -variant first — the `json://` sink had already walked into exactly that, -reporting confident dimensions for a stream whose neighbouring `resolution` -field said "unknown". - -It also does not return `(0, 0)`: that shape looks like a usable pair, so the -MP4 sink stored it and serialised a 0x0 video track — a structurally complete -file no player can render, written with no error. Before that it returned a -fabricated 1920x1080, which was wrong but at least playable, so the sink had -never needed a guard and the absence of one was invisible. A doc comment -listing which callers are safe cannot hold this: one did not check, and a -third kept its own duplicate `Unknown` test long after the accessor took the -job over. `Option` forces every caller to make its OWN decision: Matroska -omits the optional PixelWidth/PixelHeight elements, the metadata sinks report -the dimensions as absent, and MP4 must refuse per ISO/IEC 14496-12 (width and -height are mandatory in both `tkhd` 8.3.2 and VisualSampleEntry 12.1.3). - -## has_probable_video - -The exposed `streams` come from the title's FIRST PlayItem only, so a -feature whose first PlayItem is a non-video bumper reports -`has_video() == false` and would be wrongly disqualified (audit R6). This -predicate is deliberately MORE permissive than `has_video` — it only ever -ADMITS a title the stricter gate would reject — so it cannot reopen the -decoy hole: a size-inflated streamless decoy is demoted STRUCTURALLY by -composite detection in `Disc::rank_titles`, not by this gate, while it still -rejects tiny streamless menu/metadata playlists. The size fallback requires -at least one clip, so a clip-less pure-metadata decoy (which cannot be a -real bumper-lead feature — that has the feature clip) is still rejected. - -## image_crack_extents - -The extents an image-time CSS crack scans, in the crate's CANONICAL order: -the main feature's own extents, in natural playback order. Both halves are -deferred to logic the crate already owns rather than re-derived here: - -- WHICH title — `scan_with` has already sorted `titles` with - `Self::canonical_title_order`, so the canonical main feature is the first - title that actually has extents. No local pick. -- WHAT ORDER — playback order, i.e. the extent vector untouched, exactly as - `Self::decrypt_keys_for_title` hands `&title.extents` to - `css::crack_key_outcome`. - -**Why this function exists (do not let the copy grow back):** `Disc::scan_image` -used to re-implement both halves inline — it picked the title with the -largest total sector count, then re-sorted that title's extents -LARGEST-CELL-FIRST before handing them to `crack_key_outcome`. Both had -drifted from the canonical rules: - -- Largest-cell-first is the 1.5.1 garbage bug. A CSS DVD's biggest cell opens - with a long CLEAR run, and the crack's 50_000-sector budget is shared - across the whole extent list, so starting there can exhaust the budget - without ever reaching a scrambled sector — the crack reports `Unencrypted` - and the mux emits scrambled MPEG as plaintext. Playback order reaches the - scrambled feature body after only the small clear front matter that - precedes it on a real disc (CSS: the title key is recovered from the - scrambled data itself, so the scan must actually MEET scrambled data). -- The sector-count pick ignores `canonical_title_order`'s capacity gate, so - it selects the oversize "play-all" composite (whose declared cells - double-count data shared with other playlists) instead of the real feature - — i.e. a DIFFERENT title, and on a multi-VTS disc a different VTS, whose - CSS title key does not descramble the feature at all. - -The result: the same disc cracked as an ISO could disagree with the same -disc cracked from the drive, which is precisely what the duplicate was free -to do. Keep the derivation here, shared, so it cannot recur. - -## read_aacs_inputs_from_reader - -Reads a disc's AACS key-input files from a sector source: returns -`(Unit_Key_RO.inf, MKB)` raw bytes. Shared body for `Disc::read_aacs_inputs` -(ISO) and `Disc::read_aacs_inputs_from_drive` (live drive). Prefers MKB_RO, -falls back to MKB_RW, then TRIMS to the real record length. Both files are -allocated to a fixed ~128 MiB and zero-padded, so reading either ships up to -~124 MiB of nothing — trim to the record stream so callers send/store a few -MB, not 128 MiB. - -## read_aacs_version - -AACS major version (`crate::aacs::mkb::AACS_MAJOR_BD` / -`crate::aacs::mkb::AACS_MAJOR_UHD`) from the content certificate. Drives the -`Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the -out-of-band key-fetch path parses `enc_title_keys` at the right stride (a -server VUK then derives the correct unit keys). - -When no content certificate is readable/parseable, defaults to **UHD (V20, -64-byte stride)** — the conservative choice the pre-1.2.0 fetch path -hardcoded — and logs it: a wrong stride here folds a server VUK against -mis-strided title keys (silent wrong unit keys), so a missing cert must not -quietly pick the V10 stride for a UHD disc. - -## read_mkb_content - -Reads the AACS MKB's real record stream — NOT its zero padding. `MKB_RO.inf` -/ `MKB_RW.inf` are allocated to a fixed ~128 MiB and zero-padded; the actual -record stream is a few MiB. Reads a bounded prefix, finds the record-stream -length via `crate::aacs::mkb::mkb_content_len` and returns exactly that, -growing the prefix if the records run past it. This avoids reading 100+ MiB -of padding on every scan AND avoids the `read_file` `MAX_FILE_BYTES` cap that -(since 0.31.0) rejected the padded 128 MiB MKB outright — which made -`read_aacs_inputs` fail and autorip report "could not read this disc's key -files" without ever contacting the keyserver. - -## CANONICAL_TITLE_ORDER_KEYS - -**Why not just sort by duration descending?** Branching UHDs (and some BD -authoring) ship a "play-all" virtual playlist that references the same -source clips multiple times for seamless alternate-angle / alternate-ending -playback. Those playlists report an inflated `duration_secs` (often 4+ -hours) and an inflated `size_bytes` greater than the disc's physical -capacity. Example seen in the wild — *The Amateur (2025)* UHD, 58.5 GB -BD-100 disc: - -| Title | Playlist | Duration | Size | Clips | -|-------|------------|----------|---------|-------| -| 1 | 00020.mpls | 4h 13m | 92.4 GB | 253 | -| 2 | 00800.mpls | 2h 02m | 57.2 GB | 1 | - -Title 1's 92.4 GB cannot fit on a 58.5 GB disc unless the same clip data is -referenced multiple times — proof it's a virtual composite. A duration-only -sort would put it at `titles[0]`, so `freemkv -t 1`, `disc.titles.first()`, -and autorip's main-feature picker all grab the 4-hour composite instead of -the 2-hour movie that actually matches TMDB. - -**Effect on non-branching discs:** unchanged — the main movie is already the -longest 1-clip title. **Effect on branching UHDs:** the virtual play-all -playlist is pushed to the back, the actual movie surfaces at index 0. - -Defined immediately beside the comparator, so a diagnostic can NAME the -ordering instead of restating it. The `freemkv::diag` main-feature decision -row used to carry its own hand-written copy of this list, and it drifted: it -still advertised a `fewest-clips` key long after the comparator replaced -clip-count with largest-physical-size, so the `--log-level 3` bug-report log -explained freemkv's top-level pick with a rule freemkv does not apply. Any -change to the keys must change this list in the same edit. - -## probe_forced_subtitles_for_bdts_titles - -Pulled out of `scan_with` as its own callable predicate: the decision is -otherwise reachable only by driving the full scan pipeline (tree dispatch -> -title parsing -> this loop), so a test could not pin the gate without also -authoring an entire synthetic disc image. Running it against an HD-DVD/DVD -(`MpegPs`) title's extents would demux the wrong container, since only -`BdTs` titles carry PES-wrapped PGS the shared classifier -(`pgs_forced_probe`) understands. - -## MAIN_FEATURE_ORDER_KEYS - -- `nav-feature`: the title the disc's own HDMV navigation plays as the - feature, resolved by running First-Play in the `crate::bdnav` VM the way a - real player would. This is the authoritative, size-independent pick; it is - video-gated (a mis-resolve cannot select a streamless playlist) and simply - absent when the VM abstains (BD-J discs, or non-convergence), in which - case the lower keys decide. -- `authoring-feature`: a title the disc's own menu authoring designates as - the feature (has video, is not itself a composite, and is within a sane - fraction of the longest title's duration) outranks everything else — a - size-independent signal an obfuscation decoy cannot forge. -- `standalone`: a title that is a play-all / wrapper COMPOSITE — its clip - set properly contains another substantial video title's whole clip set (a - real UHD title's decoy playlist `00245` = `[bumper][00001 feature - clip][outro]` case) — is demoted below the standalone title it wraps. This - is the STRUCTURAL anti-decoy signal; it does not depend on stream counts, - physical size, or disc capacity, so it survives where those are spoofed or - unknown. -- `has-video`: a title with no plausible video content (see - `DiscTitle::has_probable_video`) is demoted below every title that has - some — a floor that drops streamless menu/metadata playlists. - -## rank_titles - -This catches both the wrapper decoy (`00245 ⊋ 00001`) and the oversize -play-all — independent of capacity, which the `fits-disc` gate needs and -which is `0`/unknown on drives that fail READ CAPACITY. - -### Wrapper-shape gate (issue #45) - -A proper-subset relation with `Q.size ≥ 0.5·P.size` is NOT by itself proof -that `P` is a wrapper. A genuine **seamless-branch feature** `P` (body clip -plus intro/credits/alternate-branch segments) is a *superset* of its -single-clip body decoy `Q`: `Q` runs almost as long as `P` (e.g. 1h50 vs -2h03, ~0.89 of the duration) yet is markedly smaller, because `P`'s branch -segments add bytes without adding much played runtime. The old size-only -gate mis-flagged such a `P` as a "play-all wrapper" and demoted the real -2h03 feature below the shorter 1h50 body — a SAFETY bug (wrong title ripped). - -So the composite flag additionally requires `P` to *have* a wrapper shape, -via one of two capacity-independent, per-title (`duration_secs`, `size_bytes`) -signals on the subset `Q`: - -- **concat / play-all:** `Q.duration < 0.85·P.duration` — `Q` is a much - shorter cut and `P` pads its runtime with other segments (the true - play-all runs ~the SUM of its parts, so each part is well under half). -- **bumper / outro wrapper:** `Q.size ≥ 0.90·P.size` — `Q` already holds - almost all of `P`'s bytes and `P` merely prepends/appends a short - non-feature lead/tail (the `00245 ⊋ 00001` decoy: `Q` is ~0.96 of both the - duration and the size, so the size limb keeps it demoted). - -Otherwise — `Q` runs almost as long as `P` (≥0.85) yet is well under 0.90 of -its size — `P` is the fuller branch set and is kept. `0.85` sits above a real -concat's per-part ratio (~0.5) and below a seamless body's (~0.89); `0.90` -sits above a bumper wrapper's near-1.0 size share and below a branch body's. - -**Known residual:** a wrapper that pads runtime only modestly AND withholds -most of its bytes from the body clip is ambiguous by shape alone. The corpus -title-selection gate is the backstop across the test corpus, and -higher-precedence nav/authoring signals override the composite key. - -## aligned_unit_keys_validate - -Conservative: a sample that is not AACS-scrambled proves nothing, and with -no scrambled sample at all there is nothing to disprove against, so it -returns `true` (accept). It returns `false` when ANY scrambled sample cannot -be restored to clear MPEG-TS by ANY unit key in the set. That covers two -distinct failure shapes: (1) a wholly wrong key (a keydb VK that does not -match this disc) — no sample decrypts; and (2) a *partially* applicable key -set on a multi-CPS-unit disc — the resolved keys cover CPS unit 0 but not -CPS unit 1. Accepting on the first sample that decrypts (the old behaviour) -would commit such a set, after which CPS-unit-1 sectors pass through as raw -encrypted bytes into the ISO/MKV with no error surfaced anywhere. Requiring -every scrambled sample to decrypt rejects the incomplete set so the caller -falls through to the next candidate. Reuses the ecosystem's single -`is_clean` content-clarity predicate and the full (bus + AACS) unit decrypt, -so it agrees with the actual mux decrypt. - -## ensure_decryptable - -The verdict, in order: -- `raw == true` → `Ok(())`. `--raw` intentionally skips decryption and needs - no key (the caller wants an encrypted image). -- `self.css_error.is_some()` → `Err(Error::CssNoDiscKey)`. The scan saw - scrambled CSS sectors but recovered no title key (`self.css` is `None` yet - the content IS encrypted). Treating `css.is_none()` as "unencrypted" would - mux scrambled MPEG as plaintext garbage. A DISC-LEVEL verdict - (`error::is_disc_level_no_key`) — the main feature's crack failed, so - every title fails the same way and the rip loop must stop rather than - skip each title in turn. -- AACS-encrypted (`self.aacs.is_some()`) with no usable key - (`decrypt_keys()` is `None`) → `Err(Error::NoDiscKey { .. })`, naming the - disc by hash. -- CSS-encrypted (`self.css.is_some()`) with no usable key → - `Err(Error::CssKeyMissing)`. (The disc-wide `decrypt_keys()` yields - `Css{..}` whenever `css.is_some()`, so this is defensive; the live - multi-VTS case is gated by `Self::ensure_decryptable_keys`.) -- otherwise → `Ok(())`. A genuinely unencrypted disc has `None` keys - legitimately, and a CSS disc whose keyless crack succeeded has a key. - -## decrypt_keys_for_title - -For a DVD the CSS title key MUST be recovered before descrambling: a -scrambled sector without a recoverable crib cannot self-crack, and CSS -leaves the pack/PES header clear, so a sector left un-descrambled would mux -as a structurally-valid but corrupt PES packet with no loss reported. Two -ways to get it: - -- **Fast path** — the scan already cracked a key whose LBA span - (`crate::css::CssState::crack_span`) covers this title's VTS: reuse it. No - re-read, and on a live drive no second CSS bus-auth round-trip. CSS title - keys are per-VTS, so an overlapping span is the same key. -- **Crack** — when up-front detection missed (`self.css == None`) or the - title lives in a different VTS: crack the key from this title's OWN - extents in a SINGLE scan, in natural PLAYBACK ORDER (never - largest-cell-first, which starved the crack in a big cell's clear prefix — - the 1.5.1 bug). Playback order reaches the scrambled feature body after - only the small clear front matter (logo / rating card) that precedes it. - One scan = one CSS-locked early-bail, so a locked title is not - re-hammered per cell against a live drive (hard rule #2); its 50k-sector - budget is the same accepted bound the disc-wide scan uses. - -Outcomes (`batch_sectors` sizes the crack's batched reads): -- `(Css{title_key}, false)` — descramble with the recovered/reused key. -- `(None, true)` — a genuinely-clear title needs no key; the gate passes it. -- `(None, false)` — scrambled but no key recoverable → hard failure via - `Self::ensure_title_decryptable`, never a silent garbage mux. - -## inject_unit_keys - -Injects pre-resolved AACS unit keys into a scanned disc — the deferred-mux / -resume path. The keys come from the mapfile's `# freemkv-uk:` header -(persisted at sweep time when the disc was keyed), so the mux decrypts -directly with NO key-service round-trip. Populates `self.aacs.unit_keys` so -`Self::decrypt_keys` returns them and marks the source `ExternalUk`. - -If the scan built no AACS state (`self.aacs == None`) — which happens when -the keydb was absent at scan time (`scan_aacs_no_keydb` -> -`aacs_error = KeydbLoad`) — this synthesizes a minimal `ExternalUk` state -for an encrypted AACS disc. A Unit Key is the FINAL per-title decryption -key; the keydb is only needed to *derive* it, and that derivation already -happened at sweep (the UK is in the mapfile). So a UK alone is sufficient to -decrypt the on-disk ISO — AACS 2.0 bus decryption was applied by the drive -at read time, so `read_data_key` is unused for file-backed mux. Without -this, a keyed disc swept without a keydb would recover its UK yet still -report E8005 (no usable `decrypt_keys`) at remux. No-op for an unencrypted -or CSS (DVD) disc. - -## scan_with_passes_the_halt_flag_to_the_bluray_enumerator - -Every BD and DVD cancellation test in this crate calls -`scan_bluray_titles` / `scan_dvd_titles` DIRECTLY, so the three-line wiring -in `scan_with` that connects the operator's Stop to them was covered by -nothing at all: replacing `opts.halt.as_ref()` with `None` on either branch -left the whole suite green while a Stop during a BD or DVD scan silently did -nothing. Only the HD-DVD branch was pinned. - -The fixture is deliberately the smallest disc that takes the BD branch — a -bare `/BDMV` with no PLAYLIST — because the point under test is the -ARGUMENT, not the enumerator's own (separately tested) halt polling. Nothing -between `scan_with`'s entry and the enumerator reads the flag (the disc is -unencrypted, so the AACS path is skipped), so a green here can only mean the -flag arrived. Mutation: `Self::scan_bluray_titles(reader, &udf_fs, None)?` -fails here. - -## halted_reads_do_not_report_the_hddvd_scan_as_successful - -A Stop on a LIVE DRIVE never touches `ScanOptions::halt`: `Drive` has its -own flag and `checked_exec` fails every SCSI command with `Error::Halted` -once it is set. The HD-DVD enumerator must not swallow that into a -successful scan. Measured before this was fixed: the scan returned `Ok` -with both titles present and ZERO streams on each — a cancelled scan -wearing the shape of a disc whose clips carry no video or audio. Downstream -that is a title list to cache, display and rip from. - -## scan_with_capacity_bytes tests - -`scan_with`'s `capacity_bytes = capacity as u64 * 2048` feeds -`canonical_title_order`'s "bigger than the whole disc = play-all composite" -threshold. - -`..._not_addition`: chosen so `capacity * 2048` clears both titles (neither -is oversize, so the bigger one — OTHER — ranks first), but -`capacity + 2048` lands BETWEEN the two sizes: MAIN stays non-oversize while -OTHER flips to oversize and gets demoted, changing `titles[0]`. A `*` -> `+` -mutation is caught by this ranking flip, not merely a wrong numeric -threshold value. - -`..._not_division`: same mechanism, tuned to catch a `*` -> `/` mutation -instead: `capacity * 2048` clears both titles, but `capacity / 2048` -collapses to a threshold far below MAIN's declared size while still leaving -MAIN under it, so only OTHER flips to oversize. - -## read_mkb_content_grows_prefix_past_16mib_when_records_run_longer - -This fixture's record stream is deliberately built so the first 16 records -land EXACTLY on the 16 MiB boundary (so the 16 MiB prefix alone parses as a -clean, complete record stream — the case a naive "n < buf.len() means done" -check would wrongly accept) and 4 more MiB of records follow, terminated by -the explicit `00 000000` end marker. Only a caller that actually grows past -16 MiB recovers the true 20 MiB content length. - -## main_feature_order_demotes_wrapper_composite_decoy - -The decoy `00245.mpls` is a WRAPPER COMPOSITE — `[00339 2s bumper][00001 the -whole feature clip][00336 outro]` — so its first-PlayItem STN reports v=0, -and it is the LONGEST and LARGEST title (8350s / 78.4 GB vs the feature's -8038s / 76.5 GB). Muxing its zero frames produced E6008 forever. It must be -demoted STRUCTURALLY (its clip set properly contains the feature's), not by -the fragile first-PlayItem stream count — this is what makes the fix robust -to the video-bearing-composite variant (see -`main_feature_order_demotes_video_composite`). - -## key_source_failure_is_not_reported_as_a_missing_disc_key - -Drives the real chain end to end — `KeySource` -> `resolve_and_apply_traced` --> `Disc::aacs_error` -> `ensure_decryptable` — twice over the SAME disc, -and asserts the two operator-visible verdicts differ: - -- source returns `Err(KeyServiceUnavailable)` (the HTTP-502 outage) → E7028 - "the key service could not answer; retry later", -- source returns `Ok(vec![])` (the service answered, no entry) → E7022 "no - key source has a decryption key for this disc". - -The credential and rate-limit verdicts are asserted alongside, since each is -a different operator action (fix the token / back off). - -## css_disc_wide_no_key_is_disc_level_while_per_title_stays_skippable - -The two CSS no-key conditions are NOT the same verdict and must classify -oppositely through the public predicates: - -- **disc-wide** — `css_error` is set: the MAIN feature's crack failed, so - every title of this disc fails identically. Must be - `crate::error::is_disc_level_no_key` (the rip loop fail-fasts) and must - NOT be `crate::error::is_skippable_title_stub`. While both conditions - shared `E_CSS_KEY_MISSING`, an uncrackable CSS disc iterated all N titles - logging "title skipped" and exited 0 — a total failure reported as - success. -- **per-title** — one title's own re-crack failed on a multi-VTS disc - (`title_is_clear == false`, no key): skipping it and finishing the rest is - correct policy, so it must STAY skippable and must NOT be disc-level. - -Pinned in both directions so a future change cannot silently flip either. - -## correct_truehd_channels_full_correction_and_atmos_promotion - -Kills the `was_basic ==`, the channels/rate `!=`/`&&` guards' "already -correct" branch, the `!matches!` per-stream skip, the `is_atmos == -Some(true)` branch, and the whole-function no-op mutant. - -## byte_offset_in_title_accumulates_across_extents - -Kills: `lba >= start && lba < end` flipped to `||` (the first extent's -disjunction would trivially match almost any lba and return the wrong, -too-early offset); `cumulative +=` flipped to `*=` (cumulative is seeded at -0, so `*=` freezes it at 0 forever); and `sector_count * SECTOR_BYTES_U64` -flipped to `+` or `/` (wrong per-extent byte length folded into cumulative). - -## chapter_at_offset_concrete_arithmetic - -Kills: the `/` (time-fraction) flipped to `%` or `*`; the `*` (duration -scale) flipped to `+`; the `<=` chapter-scan comparison flipped to `>`; the -final `chapter_idx + 1` flipped to `-` or `*`; and every whole-function -fixed-tuple replacement (none produce `(2, 60.0)`). - -## locate_ranges_positive_bps_duration_and_at_risk - -Kills `bps > 0.0` flipped to `==`/`<` (both would take the `else 0.0` -branch here, wrongly reporting 0); `(*size as f64) / bps` flipped to `%` or -`*`; and the trailing `* MILLIS_PER_SEC` flipped to `+` or `/`. - -## decrypt_keys_for_title_css_span_reuse_is_half_open - -A CSS title key is per-VTS: reusing the scan's cracked key for a title that -lives OUTSIDE the cracked span descrambles that title with the wrong key, -and the mux emits garbage at exit 0. `crack_span` is documented as the -half-open LBA span `[start, end)`, so overlap is `extent.start < span.end && -span.start < extent.end` — both comparisons STRICT. A title that merely -ABUTS the span (ends exactly where it begins, or begins exactly where it -ends) shares no sector with it and must NOT reuse the key. - -The reader serves only clear (all-zero) sectors, so a title that falls -through to its own crack is reported unencrypted — distinguishable from the -reused-key answer both in the key and in the is-clear flag. - -## merge_content_key_ranges - -Merges per-title AACS key ranges into the sorted, disjoint set the -whole-disc map needs (`crate::decrypt::AacsKeyMap::entry_for` requires -disjoint ranges). Titles that share a clip resolve the SAME physical span -(same LBAs -> same CPS unit -> same key). When a later range overlaps a kept -one that carries the SAME key index and phase, the two are UNIONED (end -extended to the max) — this covers both the exact-duplicate (shared clip) -case and any partial overlap without ever dropping coverage, so no encrypted -LBA is left in no range (which would pass through as ciphertext). A real disc -never produces two DIFFERENT keys for one LBA; if that malformed case ever -appeared, the later range is dropped to keep the set disjoint rather than -extend one key over another key's LBAs. diff --git a/docs/disc-profile.md b/docs/disc-profile.md deleted file mode 100644 index 6eb3ce56..00000000 --- a/docs/disc-profile.md +++ /dev/null @@ -1,9 +0,0 @@ -# `disc::profile` - -Everything in `DiscProfile` is DERIVED from the `Disc` model at construction: -the `Stream` enum is split into three typed vectors (audio/video/subtitle), -the `qualifier` / `purpose` enums are decomposed into booleans, and the -"default track" selection is precomputed so downstream never recomputes it. -Every field is always populated — a sensible default (`"und"` language, empty -`name`, `false` flag) stands in where the model has nothing, so consumers -never handle a bare `Option`. diff --git a/docs/disc-tests.md b/docs/disc-tests.md deleted file mode 100644 index d840f9bb..00000000 --- a/docs/disc-tests.md +++ /dev/null @@ -1,17 +0,0 @@ -# disc_tests: capacity/layer derivation - -`scan_image_reports_capacity_in_bytes_and_the_layer_count` covers the scan -reporting the medium's size on both axes it exposes, derived from the one -sector count the caller hands in: - -* `capacity_bytes` is that sector count times the 2048-byte logical sector - (ECMA-167 / BD-ROM logical block size). It is what sizes a full-disc image - read and what the progress percentage divides by, so a wrong scale is a - wrong ISO length, not a cosmetic number. -* `layers` distinguishes single- from dual-layer media. The threshold sits - between the two real capacities: a single-layer BD-25 is 12,219,392 - sectors (25,025,314,816 bytes / 2048) and a dual-layer BD-50 is 24,438,784 - sectors, so BD-25 must report 1 layer and BD-50 must report 2. - -`scan_image` takes the sector count as a parameter, so this exercises the -real derivation without a 50 GB fixture. diff --git a/docs/disc-to-rip.md b/docs/disc-to-rip.md deleted file mode 100644 index cfaef6dc..00000000 --- a/docs/disc-to-rip.md +++ /dev/null @@ -1,135 +0,0 @@ -# Disc to Rip: End-to-End Flow - -How libfreemkv goes from a disc in the drive to decrypted content ready for backup. -This is the starting point for understanding the library. - -## The Pipeline - -``` -Insert disc - │ - ▼ -1. Open drive (drive/mod.rs) - │ INQUIRY → identify drive (DriveId) - │ - ▼ -2. Init drive (drive/mod.rs → unlock seam) - │ Walk the registered-unlocker registry; first match unlocks the drive - │ (firmware/vendor handshakes are the unlocker's own business) - │ No match → drive untouched; host-cert AACS handshake carries the disc - │ Speed control → probe_disc() - │ - ▼ -3. AACS handshake (aacs/handshake.rs) — optional - │ Allocate AGID - │ Exchange certificates + nonces (ECDH) - │ Derive bus key - │ Read Volume ID + read_data_key - │ (fails gracefully if drive doesn't support AACS for this disc) - │ - ▼ -4. Read UDF filesystem (udf.rs) - │ Sector 256: AVDP → find Volume Descriptor Sequence - │ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start) - │ Metadata partition → File Set Descriptor → Root directory - │ Walk directory tree: BDMV/, AACS/, CERTIFICATE/ - │ → docs/udf.md - │ - ▼ -5. Read AACS files from disc (aacs/mod.rs) - │ AACS/Unit_Key_RO.inf → SHA1 = disc hash - │ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag - │ MKB via SCSI → for key derivation fallback - │ - ▼ -6. Resolve encryption keys (decrypt.rs → resolve_encryption) - │ BD AACS: - │ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs) - │ Path 2: KEYDB media key + Volume ID → VUK - │ Path 3: MKB + processing keys → media key → VUK - │ Path 4: MKB + device keys → subset-difference tree → VUK - │ VUK → decrypt unit keys from Unit_Key_RO.inf - │ DVD CSS: - │ Table-driven cipher — no KEYDB needed - │ → docs/aacs.md - │ - ▼ -7. Parse playlists (mpls.rs) — BD/UHD only - │ BDMV/PLAYLIST/*.mpls → titles with play items - │ Each play item: clip ID, in/out timestamps - │ STN table: video, audio, subtitle streams with codec + language - │ → docs/mpls.md - │ - ▼ -8. Parse clip info (clpi.rs) — BD/UHD only - │ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping) - │ Coarse + fine entries → full PTS and SPN - │ SPN → byte offset → sector extents for reading - │ → docs/clpi.md - │ - ▼ -9. Parse BD-J labels (labels/) — optional - │ BDMV/JAR/*.jar → Java class constant pool strings - │ 5 format parsers: Paramount, Criterion, Pixelogic, CTRM, Deluxe - │ Audio track labels: "English Descriptive Audio", "French 5.1", etc. - │ - ▼ -10. Stream content (mux/disc.rs → DiscStream) - │ Read sectors → decrypt → TS demux → PES frames - │ Or: read sectors → decrypt → raw bytes (for ISO output) - │ Drive::read() is single-shot. DiscStream::fill_extents adapts the - │ batch size on failure (halve / probe-up). Bad-range retry is layer - │ 1 above this — freemkv_engine::recovery::patch re-runs against the mapfile. - │ - ▼ - PES frames → output stream (MKV, M2TS, network, etc.) -``` - -## API Summary - -```rust -// Open + init drive -let mut drive = Drive::open(Path::new("/dev/sg4"))?; -drive.wait_ready()?; -drive.init()?; -drive.probe_disc()?; - -// Scan disc (UDF + playlists + AACS — all automatic) -let disc = Disc::scan(&mut drive, &ScanOptions::default())?; - -// Stream pipeline — PES frames from any source to any output. -// input() returns Box<dyn FrameSource>, output() returns Box<dyn FrameSink>; -// direction is type-checked, so calling .write() on an input is a compile error. -let opts = InputOptions::default(); -let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?; -let title = input.info().clone(); -let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?; -while let Ok(Some(frame)) = input.read() { - output.write(&frame)?; -} -output.finish()?; -``` - -## Module Reference - -| Module | Doc | Purpose | -|--------|-----|---------| -| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, single-shot read | -| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) | -| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem | -| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams | -| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map | -| ifo.rs | -- | DVD IFO parser | -| aacs/ | [aacs.md](aacs.md) | Key resolution + content decrypt + bus handshake | -| css/ | -- | DVD CSS cipher | -| decrypt.rs | -- | Unified decrypt dispatcher (AACS/CSS/None) | -| disc/ | [rip-recovery.md](rip-recovery.md) | Disc::scan (sweep/patch/mapfile moved to freemkv-engine in 1.6.0) | -| labels/ | -- | BD-J stream labels (5 format parsers) | -| mux/ | -- | Stream implementations (7 stream types) | -| pes.rs | -- | PES frame types + FrameSource / FrameSink traits | -| sector/ | -- | SectorSource / SectorSink + DecryptingSectorSource decorator | -| io/ | -- | Pipeline<I, R> + Sink trait + WritebackFile | -| halt.rs | -- | Halt cancellation token | -| keydb.rs | -- | KEYDB download, parse, save | -| error.rs | -- | Error codes (E1xxx-E8xxx) | -| event.rs | -- | Drive event system | diff --git a/docs/drive-access.md b/docs/drive-access.md deleted file mode 100644 index ea365eab..00000000 --- a/docs/drive-access.md +++ /dev/null @@ -1,254 +0,0 @@ -# Drive Access and Unlock - -Technical reference for how libfreemkv opens, identifies, unlocks, and reads -optical drives. - ---- - -## Drive - -`Drive` is the primary API. It owns the SCSI transport and the drive -identity (`DriveId`); any drive-specific unlock logic lives behind the -pluggable [unlock seam](#drive-unlock-seam), not in `Drive` itself. - -### Opening a Drive - -```rust -let mut drive = Drive::open(Path::new("/dev/sg4"))?; -``` - -`open()` performs: open device → send INQUIRY → build `DriveId`. The drive -is ready for `wait_ready()` and `init()` (which routes through the unlock -seam). - -### Drive Operations - -| Method | Description | -|--------|-------------| -| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) | -| `init()` | Route to the matching registered unlocker (if any), then prepare for reads | -| `probe_disc()` | Probe disc surface for optimal speeds | -| `read(lba, count, buf, recovery)` | Read sectors. Single-shot — no inline retries or reset. | -| `lock_tray()` | Prevent tray ejection during rip | -| `unlock_tray()` | Allow tray ejection (also runs on Drop) | -| `eject()` | Eject disc tray | -| `drive_status()` | Query physical state (disc present, tray open, etc.) | -| `has_profile()` | Whether a registered unlocker matches this drive | -| `close()` | Consume Drive, cleanup (also runs via Drop) | - -### init() Sequence - -`init()` routes drive preparation through the unlock seam: - -1. Walk the registered-unlocker registry; the first whose `matches()` is true - is asked to `unlock_drive()` over the raw transport. -2. Whatever that unlocker needs (firmware upload, vendor handshakes, retries) - is the unlocker's own business — libfreemkv only forwards the transport. -3. If no unlocker matches, the drive is left untouched and the library uses - the host-certificate AACS handshake. - -See [Drive Unlock Seam](#drive-unlock-seam) for the trait and registry. - -### read() — single-shot - -`Drive::read(lba, count, buf, recovery)` is the single read method. It issues -exactly one READ(10) CDB and returns the result. The `recovery` parameter only -selects the per-CDB timeout: - -| `recovery` | Timeout | Used by | -|------------|----------|------------------------------------------| -| `false` | 10 s | `freemkv_engine::recovery::sweep` fast skip-forward pass, `DiscStream::fill_extents` | -| `true` | 60 s | `freemkv_engine::recovery::patch` retry pass over the mapfile | - -On any SCSI failure or timeout, `read` returns `Err(DiscRead)` immediately. -There are no inline retries, no SCSI reset, no Phase 1/2/3 escalation. - -Recovery is layered above `Drive::read`: - -- **Layer 1 — `freemkv_engine::recovery::patch`** (in the engine crate, not - here) loops over the ddrescue mapfile and re-issues - `read(.., recovery=true)` against each non-`+` range. -- **Layer 3 — `DiscStream::fill_extents`** halves the request size on - failure, retries at the same LBA, and probes back up on a clean-read - streak. - -Inline recovery (5× gentle retry → close + reset + reopen → 5× more) was -removed in 0.13.6. See the stop-wedge postmortem (2026-04-25) -for rationale: the inline reset wedged drive firmware on the LG BU40N (Initio -USB-SATA bridge) without ever recovering a sector. See -[`rip-recovery.md`](rip-recovery.md) for the full three-layer model. - ---- - -## SCSI Transport - -### Trait - -```rust -pub trait ScsiTransport: Send { - fn execute( - &mut self, - cdb: &[u8], - direction: DataDirection, - data: &mut [u8], - timeout_ms: u32, - ) -> Result<ScsiResult>; -} -``` - -All drive communication goes through this trait. The library never opens file -descriptors or calls ioctls outside of a `ScsiTransport` implementation. - -### Platform Backends - -| Platform | Implementation | Device | -|----------|---------------|--------| -| Linux | `SgIoTransport` — synchronous `ioctl(fd, SG_IO, &hdr)` on `/dev/sg*` | `/dev/sg*` | -| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service | - -The Linux backend issues each command as one synchronous `ioctl(fd, SG_IO, &hdr)` -(`linux.rs`); the timeout is the kernel's, set via `hdr.timeout`, not a userspace -`poll()`. Opens with `O_RDWR | O_NONBLOCK`. (Pre-0.13.20 this backend ran an async -`write()` + `poll(1.5s)` + close-on-timeout + background-reopen pattern; it was -reverted in 0.13.20 — see the note at the top of `linux.rs`.) - -The macOS backend uses a C shim (`macos_shim.c`) for IOKit exclusive access. -The shim handles: -1. `shim_open_exclusive(selector)` — for `/dev/diskN`, unmounts the target via - `diskutil` and resolves its IOBDServices via the media BSD name. For an - empty drive's `ioreg:<registry-id>` selector, resolves IOBDServices directly - and skips unmount/Disk Arbitration because there is no media node. Both paths - create MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess. -2. `shim_list_drives()` — registry-based enumeration with zero SCSI, zero exclusive - access, zero unmounts. Reads IOBDServices "Device Characteristics" for - vendor/model/firmware. Uses child IOMedia "BSD Name" when media exists; - otherwise returns an opaque IOKit registry-ID selector so empty drives remain - discoverable and openable. -3. `shim_execute()` / `shim_close()` — raw CDB dispatch and cleanup. - -On non-zero SCSI status, the transport parses sense key from the sense buffer -and returns `Error::ScsiError`. - -The Linux backend has no `reset`. `SG_SCSI_RESET` and STOP+START UNIT -escalation were tried in 0.13.0–0.13.5 against the LG BU40N (Initio USB-SATA -bridge); both failed to recover wedged drives and made the wedge worse, so no -Linux transport-reset path exists today — recovery is just a fresh fd opened on -the next command. The only `reset` on a transport in the crate is -`SptiTransport::reset` on Windows (`windows.rs`). The macOS reset (which had -been a no-op) was removed entirely in 0.13.6, and the top-level -`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were -removed at the same time (no callers). - -### CDB Builders - -The `scsi` module provides platform-agnostic CDB constructors: - -| Function | CDB | Use | -|----------|-----|-----| -| `inquiry()` | INQUIRY (0x12) | Drive identification | -| `get_config_010c()` | GET CONFIGURATION (0x46) | Feature 010C firmware date | -| `build_read_buffer()` | READ BUFFER (0x3C) | All platform commands | -| `build_set_cd_speed()` | SET CD SPEED (0xBB) | Speed control | -| `build_read10_raw()` | READ(10) (0x28) with flag 0x08 | Raw sector reads | - ---- - -## Drive Identification - -`DriveId::from_drive()` sends two standard SCSI commands and extracts identity -fields: - -| Field | Source | SCSI Reference | -|-------|--------|----------------| -| `vendor_id` | INQUIRY bytes [8:16] | SPC-4 section 6.4.2 | -| `product_id` | INQUIRY bytes [16:32] | SPC-4 section 6.4.2 | -| `product_revision` | INQUIRY bytes [32:36] | SPC-4 section 6.4.2 | -| `vendor_specific` | INQUIRY bytes [36:43] | SPC-4 section 6.4.2 | -| `firmware_date` | GET CONFIGURATION Feature 010C | MMC-6 section 5.3.10 | - -The match key is `"VENDOR|PRODUCT|REVISION|VENDOR_SPECIFIC"`. Profile matching -tries all four fields first, then falls back to matching without the firmware -date for drives where Feature 010C is unavailable. - ---- - -## Drive Unlock Seam - -libfreemkv ships **no firmware, no unlock CDBs, and no drive profiles.** It -knows only the *seam*, never the *mechanism*. The seam is the `Unlocker` -trait plus a small process-wide registry (`src/unlock.rs`): - -```rust -pub trait Unlocker: Send + Sync { - /// Stable, language-neutral identifier (logged). - fn name(&self) -> &str; - - /// True if this unlocker handles the given drive. - fn matches(&self, id: &DriveId) -> bool; - - /// Put the drive into extended-access mode. The one required capability. - fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()>; - - /// Read the disc Volume ID via the drive's OEM path. Default: no-op. - fn read_volume_id(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) - -> Result<Option<[u8; 16]>> { Ok(None) } - - /// Raise the drive to its maximum read speed. Default: no-op. - fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) - -> Result<()> { Ok(()) } -} -``` - -An unlocker is supplied by an **external crate** and registered once at -process start: - -```rust -libfreemkv::register_unlocker(Box::new(some_unlocker::Plugin::new())); -``` - -The implementor owns everything about *how* a particular drive family is -driven — drive identification against its own profile database, firmware -upload, vendor CDBs, variant logic. libfreemkv only hands over the raw -`ScsiTransport` and the `DriveId`. - -### Routing - -At drive-prep the registry is walked in registration order; the first -unlocker whose `matches()` returns true is asked to `unlock_drive()` (and, -when needed, `read_volume_id()` / `set_max_read_speed()`). If no unlocker -matches, the drive is left untouched and the library falls back to the -standard host-certificate AACS handshake (the "OEM route"). The -`register_unlocker(...)` line is the entire plug: drop it (and the unlocker -crate) and libfreemkv still compiles and rips via the cert handshake. - -Concrete unlockers — including the firmware-unlock profile databases, -variant logic, and vendor CDBs that used to live in-tree — are maintained -in the separate **[freemkv-unlock](https://github.com/freemkv/freemkv-unlock)** -repository, never here. - ---- - -## Speed Control - -A matching unlocker may raise the drive to its maximum read speed via -`set_max_read_speed()` (a no-op when no unlocker matches or the unlocker -declines). The library issues SET CD SPEED (0xBB) through the generic CDB -builder; the concrete speed policy lives in the unlocker. - -Available speeds: - -| Format | Speeds | -|--------|--------| -| Blu-ray | 1x (4,500 KB/s) through 12x (54,000 KB/s) | -| DVD | 1x (1,385 KB/s) through 16x (22,160 KB/s) | -| Max | 0xFFFF (drive decides) | - ---- - -## Windows Path Normalization - -`normalize_path` in `src/drive/windows.rs` converts a device path ("D:", -"D:\\", "\\.\D:", "\\.\CdRom0") to Windows `\\.\X:` form. A near-identical -`normalize_device_path` exists in `scsi::windows`; both are kept because -they live in separate `cfg(windows)` modules that cannot easily share a -helper without introducing cross-module coupling. diff --git a/docs/drive-linux.md b/docs/drive-linux.md deleted file mode 100644 index 9e68d7d1..00000000 --- a/docs/drive-linux.md +++ /dev/null @@ -1,15 +0,0 @@ -# Linux `sg*` device enumeration - -`enumerate_sg_names` (in `src/drive/linux.rs`) lists `/dev/sgN` SCSI-generic -device names to probe for optical drives. - -Linux assigns `/dev/sgN` sequentially across *all* SCSI-generic devices -(disks, tape, HBAs, optical), not just optical drives. That means a fixed -`sg0..15` range can miss an optical drive on a host with many other SCSI -targets ahead of it in enumeration order. - -To avoid that, the function prefers the exact present-device list from -`/sys/class/scsi_generic/`, which enumerates only the `sg*` nodes that -actually exist. It falls back to a bounded `sg0..15` probe only when sysfs -itself is unreadable, which happens in some minimal containers that lack a -mounted `/sys`. diff --git a/docs/drive-mod.md b/docs/drive-mod.md deleted file mode 100644 index c8102c33..00000000 --- a/docs/drive-mod.md +++ /dev/null @@ -1,142 +0,0 @@ -# src/drive/mod.rs — internal notes - -Long-form rationale relocated from `src/drive/mod.rs` comments that exceeded -the comment-guard's internal-comment cap. Each section is pointed to by a -short `// See docs/drive-mod.md — <topic>` comment at its original site. - -## Error-recovery mode page (PER bit) - -`MODE_PAGE_ERROR_RECOVERY` (0x01) is the SBC/MMC Read-Write Error Recovery -mode page. We flip the `PER` bit to make the drive REPORT a recovered read -(via CHECK CONDITION + sense key RECOVERED ERROR) instead of silently -returning best-effort data as GOOD status. On marginal/dirty media that -silent-GOOD data can be mis-corrected — a rip that "passed clean" but decoded -with errors. With PER on, freemkv sees the marginal read and re-reads it in -Pass N (a loud miss, never a silent commit). See -`build_error_recovery_select_payload`. - -## `sleep_until_halted` — why it exists and why it was dormant - -This was `#[cfg(test)]` for two releases, kept alive only by its unit tests, -while the two production paths that actually sleep — the `wait_ready` poll -backoff (60 x 500 ms) and `spin_cycle`'s spin-down/settle pauses -(`SPIN_DOWN_IDLE_SECS` + `SPIN_UP_SETTLE_SECS`) — used a plain -`std::thread::sleep` and so were deaf to the operator's Stop for ~30 s and -~15 s respectively. Every other drive path returns `Halted` at the next -`checked_exec` boundary; a Stop pressed during spin-up simply did nothing -visible until the poll ran out. The primitive existed; the sleeping code just -did not call it. It is production code again. - -## `Drive::read` — single-shot contract and the removed inline retries - -`recovery=true` uses `crate::scsi::READ_RECOVERY_TIMEOUT_MS` (60 s, matches -sg_dd) for the `freemkv_engine::recovery::patch` pass; `recovery=false` uses -`crate::scsi::READ_TIMEOUT_MS` (10 s) for `freemkv_engine::recovery::copy`'s -fast skip-forward sweep. Both budgets are generous enough that the drive can -finish ECC recovery on a marginal sector — pre-0.13.21 this was 1.5 s on the -fast path which forced the kernel mid-layer to time out and escalate while we -waited anyway. On any failure this returns `Err(DiscRead)` immediately; -orchestration (`freemkv_engine::recovery::patch` multi-pass, `DiscStream` -adaptive batch halving) handles retry policy. - -Inline retry phases (5x gentle + reset+reopen + 5x more) were removed in -0.13.6: on some USB-SATA bridges the inline reset wedged drive firmware -without ever recovering a sector. The remaining recovery layers -(`freemkv_engine::recovery::patch` multi-pass, `DiscStream` batch halving) do -not touch the wedge-prone reset path. - -## `find_drive` — media-preference selection policy - -On a multi-drive system (common on Windows, where an empty/not-ready drive -can enumerate first) returning the first drive blindly can pick a drive with -no disc, dooming the operation. So `find_drive` opens each candidate in -enumeration order, queries `Drive::drive_status` (GET EVENT STATUS, which -works regardless of firmware state), and returns the first drive reporting -`DriveStatus::DiscPresent`. If no drive reports a disc — or `drive_status()` -is unavailable/returns `Unknown` everywhere (single-drive or quirky bridges) -— it falls back to the first drive that opened, preserving historical -behavior so those setups don't regress. For just listing drives without -opening (e.g. UI sidebar), use `scsi::list_drives()` instead — it returns -`DriveInfo` (path + identity) without the cost of running every drive's -profile + identity probe. - -## `open_block_device_for_sg` — why no `O_DIRECT` - -Resolves a `/dev/sg*` path to the corresponding `/dev/sr*` block device by -walking sysfs, then opens it for read. No `O_DIRECT`: -`posix_fadvise(POSIX_FADV_DONTNEED)` flushes the cache before each pread, -which avoids buffer-alignment requirements while still forcing fresh device -reads. Returns `None` on any error (sysfs not present, no matching block -device, open failed); callers treat that as "no fallback available" and -propagate the original SCSI READ error. - -## `resolve_device` — staged, not yet wired - -The cross-platform dispatch is kept ready for the caller that will consume -it, so the per-platform implementations below it (and their tests) stay -live. `allow(dead_code)` marks that deliberately — this is not an accidental -orphan. - -## Test rationale: recovery/halt/chunking coverage - -The `halt_tests` and `command_tests` modules exercise mutation-sensitive -edges (halt-aware sleep timing, chunked-read offset arithmetic, drive-status -byte decoding, spin-cycle/wait-ready halt responsiveness). The following -notes preserve the "why this exact scenario" reasoning that the inline test -doc comments were trimmed from: - -- **`disc_is_dvd_matches_only_dvd_profile_family`**: must match the DVD - profile family (0x0010..=0x001F) and ONLY that family. A false positive on - a BD/UHD profile (0x0040+) would skip the drive unlock UHD reads require; a - false negative on a DVD would re-introduce the CSS read failure. Widening - the range to `..=0x0040` makes the BD-ROM assert fire; a failed/short GET - CONFIGURATION must default to NOT-DVD so the unlock still runs. - -- **`media_event_reply` / media-status decoding**: MMC-6 §6.7 — byte 5 of the - GET EVENT STATUS reply is a Media Status only when the Event Header says a - media event descriptor follows (NEA clear AND Notification Class == 4). - Decoding byte 5 regardless of those preconditions reads a reserved/zero - byte as Media Status 0 and reports NoDisc on a drive that has a disc loaded - — the classic "works on my drive, not theirs" firmware split. The drive is - untrusted input, so an event-less reply must fall back to the TEST UNIT - READY status instead. - -- **Chunked-read tests** (`read_chunks_write_into_correctly_offset_buffer_regions`, - `an_undersized_buffer_errors_on_the_chunked_path_just_like_the_single_one`, - `chunk_lba_near_u32_max_errors_not_overflows`): the multi-chunk path slices - the caller's buffer by `count * 2048` and advances the per-chunk LBA with - `lba + done`, both unchecked. An undersized buffer PANICKED - ("range end index out of range") out of the public `read`/`read_fua` - instead of returning `Err(DiscRead)` like the single-chunk path; an LBA - range crossing `u32::MAX` overflowed (debug panic, or release wrap to a low - LBA silently read). These tests pin both to error-not-panic/overflow, and - one writes a distinct marker per chunk to confirm the byte-offset - arithmetic (`done * 2048`, not `done + 2048`) is correct — no other mock - touches the buffer contents, so a `*` -> `+`/`/` mutation would otherwise go - unasserted. - -- **`wait_ready`/`spin_cycle` halt tests**: `wait_ready` was the one drive - path that called `self.scsi.as_mut().execute(..)` instead of - `checked_exec`, and its 60 x 500 ms loop never read `self.halt` — a cancel - during spin-up was ignored for ~30 s. `spin_cycle` had the same gap across - two bare `execute` calls and two plain `std::thread::sleep` pauses - (`SPIN_DOWN_IDLE_SECS` + `SPIN_UP_SETTLE_SECS`), and it runs from the - recovery path — precisely when an operator is most likely to press Stop. - The tests pin: a Stop set before the call returns `Halted` immediately with - no command issued; a Stop set mid-poll (on a deterministic Nth call, no - wall-clock race) exits at the next `checked_exec` boundary; a Stop that - lands during the spin-down pause wakes the halt-aware sleep within - ~100 ms instead of blocking for the full 5 s. - -- **`read_logs_a_good_status_short_transfer`**: a READ(10) that completes - with GOOD status but a residual underrun is correctly refused, but the - refusal used to log nothing — so a drive that residual-underruns on GOOD - status produced the same silent journal as a scratched disc, and the - operator was sent after the wrong remedy (replace the drive vs. clean the - disc). The test pins that the refusal now warns with lba/transferred/ - expected/code. - -## `open_block_device_for_sg` doc contract (kept short in code) - -See the section above for the `O_DIRECT` rationale; the pointer comment near -the function keeps only the API contract. diff --git a/docs/dropgate.md b/docs/dropgate.md deleted file mode 100644 index 76bdea80..00000000 --- a/docs/dropgate.md +++ /dev/null @@ -1,69 +0,0 @@ -# `dropgate` — audio drop bookkeeping rationale - -## Keep/drop rule - -A clean mux keeps every frame it can and drops the ones it can't — video -always survives (it's inter-frame predicted; a per-frame drop would cascade, -so video resyncs/conceals instead), audio keeps every decodable access unit, -and a damaged audio AU is dropped rather than shipped as a decoder-choking -glitch. - -## Detection is per-codec - -The DETECTION is inherently per-codec — each format carries its own -authoritative corruption check (DTS: the core sync/header parse per ETSI TS -102 114; AC-3: the header CRC per ETSI TS 102 366; FLAC: the frame CRC-16; -…). `DropTally` only carries the UNIFORM response so every audio parser -behaves identically: - -1. **Count** kept vs dropped AUs and the dropped duration. -2. **Log** every drop (fail-loud, never silent) — a per-drop trace plus a - once-per-track aggregate at `warn` so it surfaces without debug logging. -3. **Whole-track fallback**: once a track is judged mostly undecodable, latch - a poison flag so the remainder is dropped too (a track that damaged isn't - worth muxing). - -## Sync preservation - -**Sync preservation is the caller's responsibility**, not this type's: the -parser must advance its PTS clock across a dropped AU exactly as it would for -an emitted one, so a drop becomes a silence gap and never a shift of the -following audio. See `DtsParser`'s `stamp_pts` call ordering for the pattern. - -## `verified_dropped` vs `dropped` - -`verified_dropped` counts AUs dropped because they were INDIVIDUALLY verified -undecodable (a failed CRC/header/parity check); only these feed the -whole-track poison verdict. `dropped` also counts *collateral* drops — AUs -discarded as a consequence of one corruption (TrueHD's resync-forward run, or -a poisoned track) — which must NOT amplify a few real errors into a false -whole-track loss. - -## `record_collateral_drop` - -A collateral drop is caused by another AU's corruption (TrueHD's -resync-forward run to the next major sync, or an already-poisoned track), not -by being individually verified undecodable. It is counted and logged for the -drop report, but deliberately does NOT feed the poison verdict, so one -corruption event can't amplify into a false whole-track loss. - -## `maybe_poison` - -Whole-track fallback: after enough AUs to judge, if more than half were -dropped the track is too damaged to be worth muxing — latch `poisoned` and -log it loudly once. The minimum-sample gate keeps a short damaged burst from -poisoning an otherwise-good track. Judge on VERIFIED drops vs all AUs seen: a -track is only poisoned when a majority of its access units are individually -undecodable — not when a couple of corruption events forced long collateral -resync runs. - -## Test: `interleaved_keeps_are_in_the_poison_denominator` - -The poison verdict is a RATIO — verified drops against every AU seen — so -the kept count is half of it. `does_not_poison_a_mostly_good_track` records -its keeps AFTER the single drop, and `maybe_poison` only runs inside -`record_drop`, so the keeps are never in the denominator when the verdict is -actually computed: that test passes even with the kept count never -incremented. Interleaving them puts the kept count on the critical path, -where losing it turns the ratio into "verified drops vs verified drops" — -always >50% — and silently discards a healthy track. diff --git a/docs/dts.md b/docs/dts.md deleted file mode 100644 index e8647ca8..00000000 --- a/docs/dts.md +++ /dev/null @@ -1,309 +0,0 @@ -# DTS / DTS-HD elementary stream parser (`src/mux/codec/dts.rs`) - -Internal design notes moved out of source comments (see `ci/comment-guard.py` -prose caps). Each section is pointed to by a short `//` comment at the -corresponding site in `dts.rs`. - -## `DTS_HD_EXT_SYNC` - -An access unit is delimited by the next CORE sync; the parser locates and -exactly sizes each extension substream (via `exss_frame_size`) so a false -core sync inside the EXSS payload can't split the AU and truncate the -lossless extension. - -## `emit_or_drop` - -Gates an assembled access unit through the decodability check and either -pushes it or drops it. `au_pts`/`dur_ns` are already stamped on the shared -PTS clock (which the caller advances whether or not the AU survives), so a -drop leaves the following audio on its true timeline — a gap, not a shift. -Every drop is logged (fail-loud, never silent). - -## `stamp_pts` - -`front` is the AU's own core-PES PTS (from `front_pts`); `dur_ns` its -decoded duration. - -The model matches the (correct) AC-3 path: **re-base to each PES's own -container timestamp, and advance by one frame duration ONLY within a run of -AUs that share the same PES.** A new PES (its `front` differs from the -previous AU's) trusts its own timestamp — so the emitted timeline tracks the -container and never drifts. Advancing within a PES is what fixes the DVD -case (several DTS core frames packed in one PES, which otherwise all -collided on that single PES timestamp → "non monotonically increasing dts"). -A global running clock was WRONG here: once accumulated frame durations -exceeded the PES spacing it never re-based, drifting the track minutes past -the real length over a feature-long title. - -## `CORE_HEADER_MIN_BYTES` - -Number of leading bytes that must be buffered before the core `fsize` field -(bytes 5-7) can be decoded. This is a HEADER-LAYOUT minimum — "enough bytes -to read the size field" — and is deliberately distinct from -`MIN_CORE_FRAME_BYTES` (the decoded-frame-size validity floor). They must -not be conflated: this one gates buffer reads of the header, the other -rejects implausible decoded sizes. - -## `MIN_CORE_FRAME_BYTES` - -Minimum plausible decoded DTS core frame size, per ETSI TS 102 114: the -on-wire FSIZE floor is 95, so a real core frame is at least 96 bytes. A -decoded `core_size` below this means we matched a false/corrupt core sync -(a lucky 0x7FFE8001 in extension-substream payload whose 14-bit `fsize` -decoded to a tiny value) rather than a real frame, so we resync instead of -closing an access unit at a junk boundary and dropping the DTS-HD extension -tail. - -## `PTS_UNSET` - -Sentinel for "no valid PTS base captured yet". Real PTS-in-ns values are -non-negative (derived from the unsigned 90 kHz PES timestamp), so a -negative value can never collide with a genuine timestamp. Used to mark the -PTS base invalid after a forced flush so the next PES sets it regardless of -buffer state. - -## `flush_tail` - -Emits the final buffered access unit (the last core + its extension -substreams, which had no following core sync to close it during -streaming), gated through the decodability check. Requires a complete core -frame; drops a bare partial sync tail. - -## `SYNCWORD_BYTES` - -Byte length shared by the DTS core sync and the DTS-HD EXSS sync (both -32-bit words). `next_core_boundary` finds the next *valid* core sync after -the current core frame, to delimit the access unit: extension-substream -payload can contain byte sequences that match the core syncword, so each -candidate is validated by decoding its core size — a match whose decoded -size is implausible (`< MIN_CORE_FRAME_BYTES` or `> MAX_AU_BYTES`) is a -false sync and is skipped, continuing the search. - -## EXSS header field bit widths - -`EXSS_USER_DEFINED_BITS`, `EXSS_INDEX_BITS`, `EXSS_HEADER_SIZE_TYPE_BITS`, -etc. are the DTS-HD extension-substream (EXSS) header field bit widths -(ETSI TS 102 114, ExtSS header). `bHeaderSizeType` selects the short form -(`nuExtSSHeaderSize` 8 bits, `nuExtSSFsize` 16 bits) or, for larger -substreams, the long form (12 / 20 bits). - -## `exss_frame_size` - -DTS-HD extension substream (EXSS) total byte size — INCLUDING the -`0x64582025` syncword — read precisely from its header. `buf` must begin -with `DTS_HD_EXT_SYNC`. Returns `None` when the size fields aren't fully -buffered. - -`nuExtSSFsize` is the total frame size in bytes minus one. Parsing it lets -the AU framer skip the extension by its exact length instead of scanning -its (arbitrary) payload for a core sync. - -## `next_core_boundary` - -Offset where the current access unit ends (the start of the next core -frame). The AU is the core frame plus its trailing DTS-HD extension -substreams, which are skipped PRECISELY by their declared size — so a -chance core syncword inside the XLL lossless payload can never be mistaken -for the next AU boundary (the bug that truncated the extension and produced -the "Failed to decode block code(s)" class). Falls back to the heuristic -core-sync scan only when an extension can't be sized (malformed / truncated -input). - -## `scan_for_next_core` - -Heuristic fallback (the pre-fix behaviour): scan forward for the next core -syncword whose decoded size is plausible. Used only when precise extension -skipping can't proceed; a chance core syncword in extension payload usually -decodes to an implausible size and is skipped. - -## `dts_core_frame_size` - -`fsize` is the 14-bit field at bits 46-59 of the header (bytes 5-7). On the -wire `fsize` is the frame length minus one, so this returns `fsize + 1`, -i.e. the core frame length in bytes (range 1..=16384). Callers treat the -result as the actual byte length and the MIN..=MAX range checks assume so. - -Returns `0` when `data` is shorter than `CORE_HEADER_MIN_BYTES` — every call -site rejects that via the minimum-frame lower bound, so a `0` is never -mistaken for a valid tiny frame. - -## `dts_core_samples` - -Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the -core-header PCM-sample-block count (ETSI TS 102 114) that fixes the frame's -decoded sample count. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5) -CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2. - -## `DTS_PCMBLOCK_SAMPLES` / `DTS_SUBBAND_SAMPLES` (core-header validity) - -DTS core-header validity constants (ETSI TS 102 114). For a NORMAL frame -`deficit_samples` must equal `DTS_PCMBLOCK_SAMPLES` — a termination frame -may carry fewer; `npcmblocks` must be a multiple of `DTS_SUBBAND_SAMPLES`; -`audio_mode` must be below `DTS_AMODE_COUNT`; `lfe_present == -DTS_LFE_FLAG_INVALID` is rejected. - -## `DTS_AMODE_COUNT` - -Number of LEGAL `AMODE` (channel-arrangement) codes. The 6-bit AMODE field -(ETSI TS 102 114 §5.3.1) has 16 defined channel arrangements, codes 0-15; -only 16-63 are reserved/user-defined and undecodable. The per-AMODE channel -counts in ETSI TS 102 114 §5.3.1 cover all 16, confirming they are -decodable — codes 10-15 are the 6/7/8-channel layouts. A frame is dropped -only when `audio_mode >= DTS_AMODE_COUNT` (i.e. a truly reserved 16-63 -code); dropping a legal 10-15 multichannel core would silence recoverable -audio. - -## `DTS_CORE_SR_VALID` - -Sample rate (Hz) per core `SFREQ` code (ETSI TS 102 114 Table 6-4); a `0` -entry marks a reserved code that fails header validation as an invalid -sample rate. Valid entries are locked to the spec by -`dts_core_sfreq_table_matches_the_dca_spec`; the reserved codes are -{0, 4, 5, 9, 10}. - -## `core_header_drop_reason` - -Decodability gate: the core-frame header validity checks from ETSI TS 102 -114. Returns `Some(reason)` when the DTS core-frame header is invalid — in -which case the packet is undecodable ("Invalid data found") and dropping it -loses nothing a decoder could have used. Returns `None` (keep) for a -decodable header OR if the header can't be fully read (never false-drop on -our own buffer underrun; the framer only emits AUs whose core is fully -buffered and ≥ 96 bytes). - -The 4-byte core sync is already validated by the framer, so this reads the -header fields that follow it. The 16-bit CPF header CRC is not verified (we -skip past it) and the audio-header/side-info CRCs are likewise not checked, -because decoders treat those bytes as optional/ignored — verifying them -would drop frames that decode fine (false positives). - -## Test: `stamp_pts_reuses_front_when_no_running_cursor_yet` - -`stamp_pts`'s third arm (`else if front != PTS_UNSET`) is only reached once -the first arm's `front != self.last_front_pts` has already failed (i.e. -`front == self.last_front_pts`) and no within-PES running cursor is -available yet (`next_pts_ns == PTS_UNSET`). In that state the AU must still -be stamped with its own (repeated) front timestamp, not silently collapsed -to 0 — a `!=` -> `==` typo on the guard would only take this arm when -`front` IS the unset sentinel, producing 0 instead here and the sentinel -value itself if front really were unset. - -## Test: `drain_front_collapses_offset_zero_markers_instead_of_leaking` - -Every drained access unit that shares its PES with the previous one pushes -a new `(0, pts)` marker once rebased; `drain_front` must collapse those down -to the single most-recent one each time, or `pts_marks` grows once per -access unit for the life of the track — an unbounded allocation on a -multi-hour disc. `front_pts()` alone can't observe this: it always finds the -last offset-0 entry regardless of how many duplicates precede it, so the -leak is invisible unless something checks that the collapse actually ran. - -## Test: `make_exss` - -Builds a real DTS-HD EXSS substream of `total` bytes (short header form), -with an optional false DTS core syncword embedded in its payload (decoding -to a plausible core size) — to prove precise sizing, not a payload scan, -bounds the extension. - -## Test: `make_dts_ext` - -Builds a minimal DTS-HD extension substream of `size` bytes (just the sync -+ zero-padding). The parser delimits extensions by the next CORE sync, not -by the extension's own size header, so a valid header isn't required — only -that the bytes carry no spurious core sync. - -## Test: `bogus_tiny_core_sync` - -Builds 4 bytes that look like a DTS core sync but whose `fsize` field -decodes to a tiny `core_size` (< MIN_CORE_FRAME_BYTES). With the dead-code -guards this passed validation and could close an access unit at a junk -boundary; with the fix it must be drained and resynced past. - -## Test: `next_core_boundary_exact_syncword_length_is_not_need_more` - -Only the 4-byte EXSS syncword is buffered after the core frame — no header -fields at all. The guard `buf.len() < pos + SYNCWORD_BYTES` must be strict -`<`: at exactly `pos + SYNCWORD_BYTES` the sync bytes ARE fully present, so -the function proceeds to identify them as an extension sync (then fails to -size the header and falls back, ending in `NextCore::None` here since -nothing after it looks like a core sync either). A `<=` typo would instead -return `NeedMore` at this exact length without ever inspecting what the 4 -buffered bytes are. - -## Test: `make_bad_dts_core` - -Builds a structurally-framed but UNDECODABLE core: a valid `make_dts_core` -whose LFE flag is set to the reserved value 3 (`DTS_LFE_FLAG_INVALID`). It -still sizes and syncs correctly (so the framer delimits it normally), but -the core-frame header validity check rejects it as an invalid LFE flag -(ETSI TS 102 114 §5.3.1: an LFF value of 3 is invalid). LFE is byte10 -bits2-1, and does NOT feed the frame duration (NBLKS + SFREQ only), so a -dropped bad core still carries the same `DTS_CORE_DUR_NS` as its good peers. - -## Test: `reparse_real_dts_file` - -Real-data fixture (ignored). Re-parses a raw `.dts` elementary stream -through `DtsParser` and writes the emitted access units back out, so the -garbage-extension → core-only drop can be validated against an actual -damaged stream (e.g. an extracted DTS-HD MA track) end-to-end with an -external DTS decoder. Env: `DTS_IN` (input), `DTS_OUT` (output). - - cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture - -## Test: `find_sync_matches_a_buffer_that_is_exactly_the_syncword` - -`find_sync` scans `0..=len-4`, so a buffer that is EXACTLY the syncword -still matches. The existing tests cover 0, 3 and "longer than 4", which -leaves the `len == 4` boundary open — and that is the case a syncword split -across PES packets lands on the moment its last byte arrives. - -## Test: `a_core_header_of_exactly_the_minimum_length_is_decoded_not_deferred` - -`CORE_HEADER_MIN_BYTES` is "enough bytes to DECODE the size field", so a -buffer holding exactly that many must be decoded, not deferred. The -distinction is visible precisely at the boundary: with 10 bytes of a core -sync whose decoded size is sub-spec, the parser must recognise the false -sync, drain past it and resync down to the 3-byte carry-over tail. Waiting -instead leaves the false sync sitting at the front of the buffer, where it -blocks every later real core behind it. - -## Test: `flush_emits_a_core_that_exactly_fills_the_buffer` - -The end-of-stream flush must emit a final access unit whose core is exactly -as long as the buffer — the ordinary case, since the last AU is closed by -end-of-stream rather than by a following core sync. Rejecting it at the -boundary silently drops the last frame of every DTS track. - -## Test: `flush_discards_a_long_buffer_that_does_not_begin_with_a_core_sync` - -The flush guard is a DISJUNCTION: a buffer that does not BEGIN with a core -sync is discarded whatever its length. Requiring both conditions instead -lets a long run of junk through to `dts_core_frame_size`, which happily -decodes a 14-bit size out of arbitrary bytes — and the flush then emits an -"access unit" that is not DTS at all. - -The junk here is sized so that mis-decoding it yields a plausible core size -that the buffer fully covers, which is exactly when the wrong answer looks -like a right one. - -## Test: `exss_frame_size_needs_the_worst_case_header_and_no_more` - -`EXSS_HEADER_MIN_BYTES` is the WORST-CASE header length — the long form's -43 bits after the syncword, rounded up to 6 bytes, plus the 4-byte sync. It -gates whether an extension substream can be sized precisely, and that is -what keeps a chance core syncword inside XLL payload from being mistaken -for the next AU boundary; too large and every extension falls back to the -payload scan, too small and the reader runs off a truncated header. - -Pinned at both sides of the boundary rather than by value, so the field -widths it is summed from stay honest. - -## Test: `an_access_unit_carries_the_source_of_the_packet_its_core_arrived_in` - -The whole point of the shared buffer: an access unit whose core arrived in -an EARLIER packet keeps that packet's source offset, not the offset of -whichever packet completed it. At a clip boundary the two belong to -different clips, and taking the later one puts the unit in the wrong clip — -which is what left nine audio and subtitle tracks unplaceable. -</content> -</invoke> diff --git a/docs/dvd-audio-probe.md b/docs/dvd-audio-probe.md deleted file mode 100644 index 81b9a6fb..00000000 --- a/docs/dvd-audio-probe.md +++ /dev/null @@ -1,149 +0,0 @@ -# DVD AC-3 physical sub-stream probing - -## Why this exists (a real-disc wrong-substream bug) - -A DVD VTS IFO declares its audio streams in a fixed table, and freemkv's -scan assigns each declared stream a `private_stream_1` sub-stream id purely -by per-codec ordinal — the first AC-3 stream becomes `0x80`, the second -`0x81`, and so on (`ifo::assign_audio_sub_stream_ids`). That assumes the -physical sub-stream order on the wire matches the IFO declaration order. - -On some discs it does NOT. A real R2 PAL DVD release's feature declares -ONE AC-3 audio stream the IFO nibble marks as 5.1 (6 channels), but the -physical VOB carries the 5.1 main mix and a 2.0 down-mix on DIFFERENT -`0x8x` sub-stream ids, and the 2.0 is the one that happens to land at the -ordinal `0x80` slot. Routing the declared 5.1 stream to `0x80` by ordinal -therefore muxes the 2.0 down-mix while labelling it 5.1 — the wrong -physical track. - -The robust fix is data-driven and codec/disc agnostic: read each physical -AC-3 sub-stream's REAL channel count from the VOB (the `acmod`/`lfeon` of -its first frame after the `0x0B77` sync) and route each IFO-declared AC-3 -stream to the physical sub-stream whose actual channel count matches the -IFO's declared count — instead of trusting the ordinal. This never -re-reads the disc beyond a bounded head-of-feature probe and degrades to -the original ordinal mapping when the probe yields nothing -(unreadable/short VOB). - -## `PROBE_SECTORS` sizing - -How many 2048-byte sectors of the first feature extent to probe. The head -of a DVD feature opens with logos/warnings whose audio is frequently a -thin 2.0 bed on the FIRST sub-stream only — the other physical `0x8x` -sub-streams and the main 5.1 mix do not appear until a sector or two -further in. 512 sectors (1 MiB) was too short: on one real disc it saw -ONLY `0x80`, and only its opening 2.0 frames. 1024 sectors (2 MiB) -reliably contains at least one frame of every physical AC-3 sub-stream -AND enough of `0x80` to reach its 5.1 frames. Still bounded so a live -drive is never hammered (see the project "don't hammer the live drive" -rule). - -## Why the maximum channel count, not the first frame - -The first frame of a sub-stream at the head of a feature is NOT -representative. A DVD opens with logos/warnings, and the main `0x80` -sub-stream there frequently carries a thin 2.0 bed before transitioning -to its real 5.1 main mix a fraction of a second later (observed on a real -disc: `0x80`'s first frames are acmod=2 → 2 channels, then it becomes -acmod=7+lfe → 6 channels within the same 2 MiB window). Recording only -the FIRST frame read `0x80=2` and missed the 5.1 entirely, defeating the -channel-match routing. The 5.1 capability of a sub-stream is the -*maximum* channel count any of its frames carries, so -`probe_ac3_substream_channels` scans them all and keeps the max. - -## `remap_audio_pids` conservatism - -Conservative — it only ever REASSIGNS among the physical sub-streams the -probe actually saw, and only when a better (exact-channel) match exists -than the stream's current assignment. A stream whose current sub-stream -already matches is left alone; a stream with no matching physical -sub-stream keeps its ordinal assignment. So a normal disc (physical order -== IFO order) is a no-op. - -## Test helper notes (`dvd_audio_probe::tests`) - -- `ac3_frame`: builds a single, correctly-SIZED AC-3 frame whose - `acmod`/`lfeon` encode a known channel count. `byte4` is - `fscod=0 | frmsizecod=0`, so `ac3_frame_size` reports 128 bytes and the - frame is zero-padded to exactly that — this lets `max_substream_channels` - advance frame-by-frame over a multi-frame payload exactly as it does on - real VOB data. The BSI bits are laid down with a writer so the test - never hand-miscomputes the lfeon offset, matching `acmod_channels`' - reader. -- `ps_ac3_frames`: builds a minimal `private_stream_1` PES carrying - `frames` for `sub_id`, each preceded only by the 4-byte AC-3 sub-header - at the PES head. Mirrors the on-disc layout the PS demux expects: PES - start `0x000001BD`, length, PES header (no PTS), sub-header - `[sub_id, frame_count, ptr_hi, ptr_lo]`, then the concatenated AC-3 - frames. - -### `probe_reads_max_channels_no_cross_contamination` - -Real-disc regression — the probe must read each sub-stream's TRUE -(max-mix) channel count, not be poisoned by an unrepresentative head -frame, and must NOT cross-contaminate between sub-streams. Mirrors the -real on-disc layout that caused the mis-read: the feature head carries -`0x80` opening with a 2.0 frame and THEN a 5.1 frame (its real main mix), -interleaved with `0x81` carrying only 2.0. The old first-frame probe read -`0x80=2` (the logo bed) and missed the 5.1; the max-over-frames probe -must report `0x80=6` and `0x81=2`. - -### `max_substream_channels_locates_sync_after_leading_non_sync_bytes` - -Regression guard for a hand-checked mutation (`+` → `-` at the -`pos + rel` offset computation): with `pos` starting at 0 and the first -sync found 3 bytes in, `pos - rel` would underflow a `usize` and panic, -or (if it somehow didn't) index the wrong start entirely. `pos + rel` is -the only computation that is always in-bounds, since `rel` is itself -bounded by the length of the slice searched from `pos`. - -### Unmappable-size fallback tests - -When an AC-3 header's `fscod`/`frmsizecod` is unmappable (reserved -`fscod == 3`), `max_substream_channels` must fall back to stepping -`start + 2` bytes past the sync to re-lock onto the next genuine sync, -and must keep making forward progress doing so (never revisit the same -sync, which would loop forever, and never jump so far that it skips the -very next real frame). - -- `max_substream_channels_unmappable_size_steps_forward_by_two` lays a - bogus-sized header at absolute offset 4 (so `start == 4`, - `start + 2 == 6`) immediately followed, at offset 6, by a real, fully - decodable 2.0 frame — the position the `+ 2` fallback must land on - exactly. -- `max_substream_channels_unmappable_size_at_start_steps_forward_not_back` - puts the unmappable-size sync at absolute offset 0 (`start == 0`) so - that stepping backward instead of forward (`start - 2`) would underflow - rather than merely land on the wrong byte. Also proves the real frame is - still found 6 bytes further in, confirming forward progress past the - bogus header. - -### `remap_reads_current_substream_via_and_not_or_or_xor` - -`remap_audio_pids` must read a stream's CURRENT physical sub-stream id -from the low byte of its PID via `pid & 0x00FF` — not `|` or `^` with -`0x00FF`, both of which force the low byte to `0xFF` regardless of the -real PID and so always miss the "already matches" shortcut. That matters -observably when TWO physical sub-streams share the same probed channel -count: with a correct read, a stream already sitting on a matching -sub-stream is left alone (conservative, per the module's documented -behaviour); with the low byte forced to `0xFF`, `probed.get(&0xFF)` is -always `None`, so the code falls through to the "find any unclaimed -match" path and picks the FIRST (lowest-keyed, BTreeMap-ordered) matching -physical sub-stream instead — which here is a *different* sub-stream -(0x80) than the one the PID already correctly names (0x81), producing a -spurious PID change. - -### `probe_and_remap_reroutes_swapped_substream_scenario_end_to_end` - -End-to-end `probe_and_remap`: a real-disc-shaped MpegPs title (one -declared 5.1 AC-3 stream ordinally assigned 0x80) whose physical VOB -bytes carry the 2.0 down-mix on 0x80 and the real 5.1 on 0x81. This must -reach the `remap_audio_pids` call and re-route the stream to 0xBD81. It -also, by construction, proves each of the guards along the way lets a -real, positive case through: the content-format check must NOT bail on -`MpegPs` (only on non-`MpegPs`), the AC-3 presence check must NOT bail -when AC-3 IS present, and the sector-count check must NOT bail when the -count is nonzero — any one of those inverted would skip the probe -entirely and leave the PID at its untouched ordinal value (0xBD80), which -the test's assertion would catch. diff --git a/docs/dvd.md b/docs/dvd.md deleted file mode 100644 index 45d7ab7c..00000000 --- a/docs/dvd.md +++ /dev/null @@ -1,177 +0,0 @@ -# DVD Title Scan (src/disc/dvd.rs) — Internal Notes - -Rationale, regression history, and test-fixture layout notes that were -originally inline comments/doc-comments on private items in -`src/disc/dvd.rs`, relocated here to satisfy the comment-guard's internal- -comment cap. Each pointed-to site in the source carries a short `// See -docs/dvd.md — <topic>` line. - -## `Disc::scan_dvd_titles` halt handling - -Cancellation: `halt` is polled before the IFO tree is read, and an IFO read -that fails with `Error::Halted` — how a live drive reports a Stop, since -`Drive::checked_exec` fails every command once its flag is set — is -propagated rather than swallowed. Every other IFO failure keeps its -best-effort `Ok(vec![])`. - -It has to be an error and not an empty title list, for the same reason -spelled out on `Disc::scan_hddvd_titles`: a cancelled enumeration that -returned `Ok` would be indistinguishable from a disc that genuinely holds -fewer titles. This one was the worst of the three enumerators — a bare -`Err(_) => return Vec::new()` turned an operator Stop into ZERO titles at -rc=0, a disc reported as carrying no video at all. - -`scan_dvd_titles` returns the scanned titles plus, when the disc's own -First-Play navigation deterministically dispatches to a title, that title's -`playlist_id` — the nav-resolved main feature. The caller feeds it into -`sort_titles_by_main_feature` as the DVD `nav_feature`, the same seam the BD -path uses. It is `None` (existing ranking heuristics stand) whenever the -navigation is menu-only, malformed, or does not converge. - -## DefaultDuration / cadence TODO - -`measured_cicp` TODO(spec): populate `top_field_first` + measured colour -from `Mpeg2Parser` once tracks are built after frame parsing — needs a -`CodecParser`->title channel like `codec_private`. Until wired, `None` -falls back to TFF / PAL-NTSC guess. - -DefaultDuration TODO(spec): comes from IFO `frame_rate`, so a -soft-telecined 23.976-in-29.97 DVD reports 29.97 instead of true film -rate. Cadence is detectable via `nb_fields` (2:3 pulldown); needs the same -channel as `top_field_first` above. - -## Test fixture builders: `build_vmg` / `build_vts` - -`build_vmg` builds a VIDEO_TS.IFO: magic "DVDVIDEO-VMG"@0, TT_SRPT sector -ptr@0xC4. TT_SRPT lives at `tt_srpt_sector*2048`: `num_titles(u16)`@0, then -12-byte entries from +8. Each entry: `num_chapters(u16)`@+2, `vts_number`@+6, -`vts_title_num`@+7. - -`build_vts` builds a VTS_XX_0.IFO. Layout per ifo.rs: -- magic "DVDVIDEO-VTS"@0 -- `vtstt_vobs` (Title VOBS start sector, u32 BE)@0xC4 — the production - `vob_start_sector` the cell sectors are relative to. (0xC0 is - `vtsm_vobs`, the menu VOBS, which the scan must NOT use.) -- VTS_PGCIT sector ptr(u32 BE)@0xCC -- video attr byte@0x200 -- `num_audio(u16 BE)`@0x202, audio blocks (8B) @0x204 -- `num_subs(u16 BE)`@0x254, subtitle blocks (6B) @0x256 - -PGCIT (at `pgcit_sector*2048`): `num_pgcs(u16)`@0, PGC info entries (8B) -from +8 with PGC byte offset(u32 BE)@+4. PGC: `nr_programs`@0x02, -`nr_cells`@0x03, BCD time@0x04, `pgm_map` ptr@0xE6, `cell_playback` ptr@0xE8 -(both u16 BE rel to PGC start). - -## `HaltingReader` test double - -A `SectorSource` that fails every read at or above `halt_at` with -`Error::Halted` — how a LIVE DRIVE behaves once the operator presses Stop: -`Drive::checked_exec` fails every SCSI command with `Halted` from then on, -and `Drive::read` deliberately preserves the variant. Reads below the -threshold still succeed, so the scan gets far enough to have something to -truncate. - -## Test: `halted_ifo_read_is_not_reported_as_a_shorter_disc` - -A Stop on a LIVE DRIVE never touches `ScanOptions::halt`: `Drive` has its -own flag and `checked_exec` fails every SCSI command with `Error::Halted` -once it is set. The DVD enumerator must not swallow that into a -successful scan. - -This was the worst of the three enumerators. RED BEFORE GREEN, two -distinct swallows, both measured with the fix reverted: -- `ifo::parse_vmg` treats a failed title set as a placeholder entry and - continues, so a cancel landing on VTS_02's IFO returned - `Ok([VTS_01_1.VOB])` — one title from a two-title disc. -- `scan_dvd_titles`'s `Err(_) => return Vec::new()` turned a cancel landing - on VIDEO_TS.IFO itself into ZERO titles at rc=0 — a disc reported as - holding no video at all. - -Both are indistinguishable from a real disc, and both are now -`Err(Error::Halted)`. - -Fixture: two title sets, VTS_01's IFO at `PART_START+6000`, VTS_02's at -`PART_START+7000`. Both ICBs sit far below, so filesystem metadata -resolves fine and only the second title set's CONTENT read is cancelled — -the truncation case. - -## Test: `scan_dvd_titles_uses_title_vobs_not_menu_vobs` - -Regression (first-play menu prepended to the feature): `vob_start` must -come from the **Title** VOBS pointer `vtstt_vobs` (VTS_IFO 0xC4), NOT the -**menu** VOBS pointer `vtsm_vobs` (0xC0). On discs with a per-title menu — -e.g. the Universal "the parental level has been set, press yes" first-play -still — `vtsm_vobs` points at that menu VOB, which sits just before the -title VOB. Cell `first_sector` values are relative to `vtstt_vobs`; reading -0xC0 prepended the menu and shifted every extent back by -`vtstt_vobs - vtsm_vobs`, so the rip opened on the parental prompt instead -of the movie (one real NTSC R1 disc: vtsm=44, vtstt=3640). - -Here `build_vts` stamps `vtstt_vobs = 3640` (0xC4); the test additionally -stamps a *different* `vtsm_vobs = 44` (0xC0). The extent must resolve -from 3640. - -## Test: `scan_dvd_titles_extent_is_absolute_three_term_sum` - -ABSOLUTE-REBASE regression (real-disc menu-at-start fix): `ifo::parse_vts` -now sets `vob_start_sector = file_start_lba(IFO) + vtstt_vobs`, so an -extent's `start_lba` must equal the sum of THREE independent terms — the -IFO file's absolute on-disc LBA, the IFO-relative `vtstt_vobs` (0xC4), and -the cell's `first_sector` — none of which may be dropped. The earlier code -used the bare relative `vtstt_vobs`, placing every extent `ifo_lba` -sectors too early (the rip opened in the VMGI/menu region before drifting -into the movie). The other tests fold two of the three terms together -(zero cell offset, or a single combined expectation); this one keeps all -three distinct and non-overlapping so a regression to ANY two-term -combination is caught. - -## Test: `scan_dvd_titles_lpcm_routes_to_a0_pid_range` - -LPCM SCAN ROUTING (audit §2 / §5 #6): the 0xA0..=0xA7 PID range was never -exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) at audio -position 1 must get `sub_stream_id 0xA1` → PID `0xBDA1` via -`dvd_audio_pid`, distinct from the AC-3 `0xBD80` space, with its real -channel count preserved. - -## Test: `scan_dvd_titles_multiple_vobsub_tracks_distinct_pids` - -MULTI-VOBSUB SCAN (audit §2 / §5 #6): the single-subtitle test covered one -track; a multi-subtitle VTS must emit one `Stream::Subtitle` per entry -with distinct PIDs (`0x20 + ordinal`) and per-language tags, all sharing -the PGC palette `codec_data`. - -## `stamp_first_play_jumptt` test helper - -Stamp a First-Play PGC into a VMG whose pre-command list is a single -unconditional `JumpTT ttn`, so `dvdnav::resolve_main_title` resolves that -title through TT_SRPT. Placed in sector 0, clear of the magic / FP_PGC / -TT_SRPT pointers `build_vmg` already writes. - -## Test: `scan_dvd_titles_nav_promotes_first_play_target` - -The DVD First-Play nav promotion branch (issue #40): when -`resolve_main_title` returns a `(vtsn, vts_ttn)` target, `scan_dvd_titles` -must map it — via the 1-based `vts_ttn == vts_title_idx + 1` join within -the matching `vts_number` — to the running `title_number`, and surface it -as the `nav_feature`. TT_SRPT here maps title 2 → (VTS 2, title-in-set 1); -the First-Play unconditionally `JumpTT 2`, so the promoted feature must be -the SECOND scanned title (VTS_02 → title_number 2). An off-by-one on the -join or a `vtsn`/`vts_ttn` field swap would promote the wrong title or -none. - -## Test: `scan_dvd_titles_drops_leading_scene_index_cell` - -End-to-end bug-4 fix: a feature PGC that opens with a leading interleaved- -angle sub-block cell (category 0x90 = in-block cell of an angle block) -must have that cell DROPPED from the muxed extents, so the rip starts at -the real feature. Chapters shift earlier by the dropped duration. - -## Test: `scan_dvd_titles_mp2_audio_pid_fallback_is_additive` - -Audio PID fallback (dvd.rs `Disc::scan_dvd_titles`): when an audio stream -has no on-wire private_stream_1 sub-stream id — MP1/MP2 audio, per -`ifo::assign_audio_sub_stream_ids` — the PID falls back to `0xBD00 + i` -where `i` is the stream's positional index in the IFO audio-attribute -table. Two MPEG-audio (coding_mode 2) streams must land on two DISTINCT, -correctly-offset PIDs: 0xBD00 and 0xBD01. This pins the `+` (not `-`/`*`) -so the second stream doesn't collide with, or wrap under, the first. diff --git a/docs/dvdnav-mod.md b/docs/dvdnav-mod.md deleted file mode 100644 index 1009597e..00000000 --- a/docs/dvdnav-mod.md +++ /dev/null @@ -1,23 +0,0 @@ -# src/dvdnav/mod.rs - -## Module rationale - -Byte layout follows the DVD-Video specification (VMGI/VTSI headers, -PGC/cell tables, PCI/HLI button packets); the VM command decoder is -verified against real discs. - -Current contents: `vmcmd` — the VM command decoder; `nav` — the -First-Play navigation executor that resolves which title the disc's own -navigation selects as the feature. - -## `resolve_feature_start` — parked status (issue #40) - -PARKED (issue #40, `USE_NAV_RESOLVER=false`): this is intentionally a stub, -not an accidental dead one. It unconditionally returns `None` and ignores -`reader`/`udf` on purpose — the IFO/PCI parsing + nav executor (built on -`vmcmd`) land incrementally behind that flag. Until wired in, calling this -is behaviour-neutral (the caller always falls back); improvements to the -resolver take effect here without touching the call site. Do not delete. - -`reader`/`udf` are the seam inputs the nav executor will consume to read -VIDEO_TS.IFO + the VTS IFOs/menu VOBs. Reserved until that lands. diff --git a/docs/dvdsub-mod.md b/docs/dvdsub-mod.md deleted file mode 100644 index 609cfb90..00000000 --- a/docs/dvdsub-mod.md +++ /dev/null @@ -1,28 +0,0 @@ -# dvdsub — VobSub SPU boundary rule - -The presence of a PTS — not merely an open `pending` — is the authoritative -SPU-boundary signal, so a lost continuation or a corrupt SPU_size can't merge -the next subtitle into the stuck unit. - -## ycbcr_to_rgb range convention (deliberate) - -This uses the **full-range (JFIF) BT.601** coefficients with no 16/235 luma -scaling. DVD IFO palette YCbCr is nominally studio-swing BT.601, so -studio-swing math would be more colorimetrically "correct" in isolation. But -the output here is a VobSub `.idx` `palette:` line, and the entire VobSub -ecosystem (the original tooling, mkvtoolnix, players that read the `.idx` -palette) is built around this full-range formula — it is the de-facto -on-disk convention. Emitting studio-swing-scaled RGB here would make -freemkv's palettes inconsistent with every other tool and wrong in players -that assume the VobSub convention. Do NOT "fix" this to studio-swing without -changing the consuming side in lockstep. - -## ycbcr_to_rgb_reads_byte2_as_cr_and_byte3_as_cb test fixture - -A saturated RED entry appears on disc as Y=76, Cr=255, Cb=85 (full-range -BT.601 encoding of RGB #FF0000), i.e. bytes `[0x00, 76, 255, 85]`. Reading -byte 2 as Cb and byte 3 as Cr instead turns this entry BLUE, which is the -exact user-visible symptom. The fixture is deliberately NOT built from this -crate's own doc comments: those described the order wrongly for a long time, -and the previous version of this test inherited the error from them and -therefore could not detect it. diff --git a/docs/ebml.md b/docs/ebml.md deleted file mode 100644 index d3f577d5..00000000 --- a/docs/ebml.md +++ /dev/null @@ -1,62 +0,0 @@ -# src/mux/ebml.rs - -## `fixed_width_vint8` - -Encodes `data_size` as the FIXED-WIDTH 8-octet EBML VINT used to back-patch -a master element's size field: the `0x01` VINT_MARKER octet followed by the -56-bit VINT_DATA payload, big-endian (RFC 8794 section 4.4 — an 8-octet -VINT carries 7 octets of VINT_DATA). - -Extracted so the full 56-bit payload can be exercised directly: reaching -the high payload bytes through `end_master` / `end_master_buf` would take -a multi-terabyte buffer, leaving them unconstrained by any test. - -`data_size` must be below 2^56; both callers check that first. - -## `start_master_buf` / `end_master_buf` - -This is the seek-free twin of `start_master`/`end_master` for elements -small enough to assemble whole before hitting the file (a BlockGroup: one -Block plus a couple of tiny elements). Because `end_master` always -back-patches a FIXED-WIDTH 8-byte VINT (`0x01` + 7 payload bytes) rather -than a minimal-width one, the bytes produced by this pair are byte-for-byte -identical to the seek-and-back-patch pair — assembling in memory cannot -change the emitted Matroska. - -## `read_exact_bounded` - -Reads exactly `len` bytes WITHOUT trusting `len` to size the allocation. - -`vec![0u8; len]` on an attacker-controlled EBML size would allocate -gigabytes before the read fails. Instead the reader is capped to `len` -and the buffer grows as bytes actually arrive: a malformed element that -claims a huge length but supplies few bytes allocates only what it -delivers, then errors on the short read. - -## Test: `buffered_master_matches_seeking_master_byte_for_byte` - -The buffer-based master helpers must produce byte-for-byte what the -seek-based ones produce. `write_block_group` was rewritten onto -`start_master_buf`/`end_master_buf` to eliminate ~4 seeks (and 4 MiB -BufWriter flushes) PER FRAME, which on an MPEG-2 title is ~350k frames — a -change only safe because the two encodings are identical. - -Both write a FIXED-width 8-byte VINT placeholder (0x01 + 7 payload bytes) -and patch it in place; neither ever emits a minimal-width size. This test -pins that, so a future "optimisation" of either one to minimal-width sizes -cannot silently desync the two and change emitted Matroska. - -## Test: `buffered_master_nests_correctly` - -Nested masters must patch correctly too — the MVC BlockGroup nests -BlockAdditions > BlockMore inside the BlockGroup, and in-memory patching -works by index rather than by file offset, so nesting is where an -index-arithmetic slip would show up. - -## Test: `fixed_width_vint8_is_big_endian_over_the_full_payload` - -The fixed-width 8-octet VINT is `0x01` followed by the 56-bit VINT_DATA -payload in BIG-ENDIAN order (RFC 8794 section 4.4). Every payload octet is -distinct here, so a shifted, reversed or dropped octet is visible; the top -payload octets are unreachable through `end_master` without a -multi-terabyte buffer, which is why this is tested at the encoder. diff --git a/docs/encode.md b/docs/encode.md deleted file mode 100644 index d8af79d3..00000000 --- a/docs/encode.md +++ /dev/null @@ -1,51 +0,0 @@ -# `dirimage::encode` — descriptor encoder notes - -Overflow rationale for comments in `src/dirimage/encode.rs` that would -otherwise exceed the comment-guard caps. Each section below is pointed to -by a short `//` comment at the relevant spot in the code. - -## Why a pure function - -`encode` is a pure function from `Layout` to sectors — nothing here touches -the filesystem. That is what makes it testable against the production -parser in `udf.rs`: the same bytes this module writes are read back and -checked by the real UDF reader, so the encoder and the parser can't drift -apart silently. - -## Why UDF 1.02 with a single Type-1 partition map - -UDF revision 1.02 with a single Type-1 partition map is deliberate: it is -the DVD-Video profile, it is the shape `read_filesystem` takes when -`num_partition_maps < 2`, and it avoids the UDF 2.50 Metadata Partition -entirely. That also means a synthetic image never exercises the Metadata -Partition path in `udf.rs` (`:946-991`). - -## `finish_tag`: why `tag_loc` correctness matters - -Getting `tag_loc` wrong (absolute vs. partition-relative) is the classic -reason a hand-built volume mounts nowhere: a driver that validates the tag -location rejects the descriptor outright. `desc_len` is the descriptor's -total length including the tag; the CRC covers `buf[16..desc_len]`. - -## `encode_cs0`: why ASCII, not Latin-1, for the 8-bit form - -`parse_udf_name` (`udf.rs:1467`) decodes a compression-8 name with -`from_utf8_lossy`, so a 0x80-0xFF byte — legal CS0 under Latin-1 — would -come back as U+FFFD. Every character above 0x7F therefore takes the 16-bit -(UTF-16BE) form, which that parser decodes correctly. - -## `file_entry`: why tag 261, not the Extended File Entry (266) - -Real BD-ROMs use the Extended File Entry (tag 266), but an EFE requires -UDF 2.00+, and this image declares 1.02, so `file_entry` writes the plain -File Entry (tag 261) instead. `udf.rs` reads both — the 261 field offsets -it uses (`l_ea` 168, `l_ad` 172, ADs at `176 + l_ea`) are the ones written -here. - -## `push_fid`: why FIDs may span logical blocks - -FIDs are packed with no inter-descriptor padding beyond the 4-byte -alignment the spec mandates, and they are allowed to span logical blocks — -which is also what `read_directory` (`udf.rs:1312`) assumes: it walks the -directory extent as one flat byte run and stops at the first non-257 tag, -so any block-alignment gap would truncate the directory. diff --git a/docs/encrypt.md b/docs/encrypt.md deleted file mode 100644 index 0fc1f15c..00000000 --- a/docs/encrypt.md +++ /dev/null @@ -1,151 +0,0 @@ -# encrypt.rs — relocated rationale - -Overflow prose that would not fit under `ci/comment-guard.py`'s caps, -moved here per file with a `// See docs/encrypt.md — <topic>` pointer -left at the original site. - -## HandshakeResult::drive_unlocked - -True when the VID came from an unlocker (in `freemkv-unlock`) that -unlocked the drive. Such a drive serves CLEAR content, so AACS bus -encryption is already removed AT THE DRIVE — the same end state a -successful cert handshake's `read_data_key` provides, just via -firmware instead of the AKE. The bus-key gate MUST credit this as a -valid bus-removal: bus encryption is unremovable only when NEITHER the -firmware unlocked the drive NOR the cert handshake yielded a bus key. -Without this, a SUCCESSFUL unlock (VID present, `read_data_key: None`) -paradoxically trips the gate and blocks ALL key resolution (incl. the -online source). - -## bus_encryption_removed - -Single source of truth for "is AACS bus encryption gone for this -scan?". The gate asks ONLY this — `if !removed { error }` — never -enumerating cases. Bus encryption is gone when ANY of these holds: -the disc never had it; file/ISO reads (content already clear at read -time); an unlocker unlocked the drive (it serves clear content); or -the cert handshake produced the bus key. Add a NEW removal mechanism -HERE, never in the gate. - -## AacsCertUnlocker - -libfreemkv-side driver for the AACS cert route. It owns the host-cert -collection (a keysource concern that stays in libfreemkv) and then -dispatches the actual mutual-auth to the `freemkv-unlock` AACS -unlocker via the `crate::unlock_bridge`. The firmware (drive-prep) and -CSS routes dispatch the same way at their own call sites; this one -carries the host certs. - -## AacsCertUnlocker::authenticate - -Runs the host-certificate mutual-auth handshake: collect -non-compiled-in host certs from the key sources + credentials, then -hand them to the AACS unlocker (via the `freemkv-unlock` dispatch), -which tries each cert (wedge-guarded) and on success yields the -Volume ID + `read_data_key` (the AACS 2.0 bus key). Returns a -`CertUnlockFailure` on every no-VID outcome. - -## unlock_error_to_error - -Maps a `CertUnlockFailure` back to the `Error` variant -`do_handshake_cert` has always surfaced, so `scan_with`'s rendering -and the path-1 disc-hash → VUK fallback are byte-for-byte unchanged. -(`NoHostCert` keeps the `<no host cert>` sentinel.) - -## handshake_has_volume_id - -Did the cert handshake actually carry a Volume ID? Extracted so it can -be tested as a VALUE. It only ever reaches an operator as the -`has_volume_id` field of the `bus_key_unavailable` warn, and asserting -on a `tracing` field means installing a capturing subscriber — which -is thread-local, while `tracing`'s callsite-interest cache is global. -Those two facts race: the test failed roughly one run in ten under the -full parallel suite while passing every time in isolation, and -serialising the captures was not enough because the cache can be -re-evaluated against the process default rather than the thread-local -dispatch. - -A predicate this small does not need a subscriber to verify. The -polarity is the whole point: an `==` here would tell an operator a VID -was absent on exactly the discs where one was present. - -## Disc::do_handshake - -SCSI handshake — drives the VID-acquisition flow and returns a -structured `HandshakeResult` for downstream key resolution. - -VID acquisition runs through `do_handshake_cert`, which first uses the -OEM VID a firmware unlocker may have stashed at drive `init()` (a -drive-functionality capability decoupled from the host cert + HRL) and -falls back to the cert-based mutual-auth handshake (dispatched to the -`freemkv-unlock` AACS unlocker) when none is present. The cert path -also yields `read_data_key`, required for AACS 2.0 bus decryption. - -Returns `(handshake, error)`: -- `(Some(_), None)` — VID acquired -- `(None, Some(_))` — specific failure mode (`AacsHostCertRejected` or - `AacsVidUnavailable`) -- `(None, None)` — handshake not attempted (no keydb; resolution will - proceed with VID=zero and rely on path 1 disc-hash → VUK lookup) - -## Disc::do_handshake_cert - -Cert-based AACS handshake — the cert route for VID acquisition. - -Before running the cert mutual-auth, this checks for an OEM Volume ID -a firmware unlocker stashed at drive `init()`. Such an unlocker -unlocks *drive functionality*, not just the disc: VID retrieval via -the drive's OEM CDB is a capability separate from `unlock`. When one -served a VID, we use it and SKIP the cert handshake entirely — the -OEM path gets the VID *without* the host certificate + HRL, decoupling -VID from the cert chain. The OEM path yields no `read_data_key` (no -bus-key is derived); AACS 2.0 content needing read_data_key for bus -decryption must still use the cert path, so an unlocker with no OEM -VID capability returns `None` and we fall through to cert auth -unchanged. - -## Disc::collect_host_certs - -Collect every AACS host cert the caller carries, from BOTH the -explicit `DriveCredentials` and the key-source layer -(`crate::KeySource::host_certs` across each source), unioned. Host -certs are keysource-served, never compiled in; this is the one place -the OEM cert route gathers them. An empty result is the graceful -no-cert signal (the caller turns it into `Error::AacsNoHostCert`). -`mkb` is the disc's MKB generation when known, forwarded to each -source's `crate::KeySource::host_certs` so a source MAY return only -generation-appropriate certs (the default ignores it). - -## Disc::resolve_vid_only - -Build a keys-free AACS state that carries only the Volume ID (+ -version metadata), for callers that resolve Unit Keys out-of-band and -have disabled the local keydb. The VID is on-disc content read during -the handshake; preserving it here lets the out-of-band path use it. No -keys are present (`unit_keys` empty, `vuk` None), so the disc reports -as "encrypted, no keys" until the caller re-scans with a resolved Unit -Key. - -## build_mkb (test helper) - -An MKB with one Type-and-Version record (type 0x10) carrying the -version as BE u32 at record offset 8, followed by a recorded EOF -record then trailing zero padding. mkb_content_len walks records and -stops at the first padding (type 0) byte (aacs/inf.rs). - -## resolve_vid_only_no_cert_defaults_version_uhd (test) - -No content cert at all → version defaults to UHD (major 2), matching -`read_aacs_version` so the scanned `AacsState.version` and the -out-of-band fetch agree on the Unit_Key_RO stride (audit #4: a wrong -BD-vs-UHD guess mis-parses unit keys). bus_encryption false -(unreadable → off). - -## Skipped/deferred test coverage - -Tests for `read_vid_oem` (response parsing) are skipped in this file -since `Drive` construction requires a live transport; parsing branches -are exercised through `read_vid_oem`'s callers in integration. The -`collect_host_certs` tests union `DriveCredentials` with the -key-source layer; empty means the route fails gracefully -(`AacsNoHostCert`). diff --git a/docs/error-codes.md b/docs/error-codes.md deleted file mode 100644 index f7138c38..00000000 --- a/docs/error-codes.md +++ /dev/null @@ -1,181 +0,0 @@ -# Error Codes - -Overflow detail for `src/error.rs` doc comments that would otherwise exceed -the comment-guard caps. Each section below is pointed to by a short `//` or -`///` comment at the corresponding definition. - -## Error Code Ranges - -| Range | Category | -|-------|----------| -| E1xxx | Device errors | -| E2xxx | Profile errors | -| E3xxx | Unlock errors | -| E4xxx | SCSI errors | -| E5xxx | I/O errors | -| E6xxx | Disc format errors | -| E7xxx | AACS errors | -| E8xxx | Keydb errors | -| E9xxx | Stream/mux errors | - -## E_UDF_NO_USABLE_EXTENT - -A file that EXISTS and whose allocation descriptors resolved without error, -yet yields not one usable extent: an empty AD list, or a list every entry of -which is zero-length or points at LBA 0. Reported by the HD-DVD clip resolver -(`disc::hddvd`). - -It has no `Error` variant on purpose. `UdfFs::file_extents` returns -`Ok(vec![])` here rather than failing — the emptiness is only a defect in the -eye of a caller that needs bytes — so the condition is DETECTED by the -caller, not returned to it. The code exists so that detection can be -accounted in the log with something other than a neighbouring error's code: -logging it as E6017 would file a zero-length AD list as an authoring hole -and send whoever triages it at the wrong population. - -## E_CSS_NO_DISC_KEY / CssNoDiscKey history - -While the disc-wide CSS failure and the per-title CSS failure both used -`E_CSS_KEY_MISSING`, an uncrackable CSS disc was iterated title by title, -each one logged as skipped, and the run exited reporting success — a total -failure reported as success. `E_CSS_NO_DISC_KEY` / `Error::CssNoDiscKey` was -split out so `is_disc_level_no_key` classifies it and a multi-title rip loop -fails fast on it instead. - -## E_KEY_SERVICE_UNAVAILABLE - -A seven-hour run of HTTP 502s reported as `E_NO_DISC_KEY` told operators -their disc was not in the key database and sent them hunting for a VUK that -was never missing; the correct action was to wait. `E_KEY_SERVICE_UNAVAILABLE` -exists so a key-source outage (transport error, DNS failure, timeout, TLS -failure, HTTP 5xx, unreadable reply) is distinguishable from a source that -answered and simply has no key. - -## E_MKV_SOURCE_INVALID vs E_MKV_INVALID - -`is_skippable_title_stub` classifies `E_MKV_INVALID` as a title that yielded -no muxable frames, which an all-titles rip may skip while finishing the -rest. A corrupt or truncated `mkv://` source file is a FAILURE, not a stub — -reporting it as skippable would let a broken source be silently passed over -by a run that then exits successfully. `E_MKV_SOURCE_INVALID` (and the -sibling codes `E_MUX_HEADER_BUFFER_EXCEEDED`, `E_MKV_LACING_INVALID`) exist -to keep that distinction, and are fatal. - -## UdfEmbeddedData - -A file that legitimately stores its data this way is tiny (an ICB caps it at -well under 2 KiB — the AACS `*.inf` key files are the usual case), and the -callers that expect one read it via `read_inline_data` long before extents -are ever requested. A stream file cannot be one. `read_directory` already -refuses the same shape for directories; `UdfEmbeddedData` is the file half -of that decision. - -## MkvInvalid / MuxHeaderBufferExceeded history - -`MuxHeaderBufferExceeded` exists because hundreds of megabytes of real -frames with unresolvable codec-init headers is NOT the same thing as -`MkvInvalid`'s canonical case (an empty nav/menu PGC stub). Reporting the -former as the latter would have `is_skippable_title_stub` silently drop a -main feature from a rip that then exits successfully. - -## AacsBusKeyUnavailable - -NOT raised for AACS 1.0 BD (no bus encryption, `read_data_key` legitimately -absent) nor for file-backed (ISO) scans, where bus encryption was already -removed at read time and no handshake runs. Only a live-drive AACS 2.0/UHD -scan that obtained the Volume ID but produced no bus key raises this. - -## SourceTerminated - -The alternative answer is a lie: a dead source that reports `Ok(0)` is -indistinguishable from end-of-stream, and `DiscStream::fill_extents` -legitimately reads a short count as a skippable hole — zero-filling and -advancing over every remaining sector of the title and still returning -success. Unlike a bad sector, this condition cannot be retried at a smaller -size or skipped past. - -## error_code / From<Error> for io::Error round-trip - -`mux_stream` hands back an `io::Error`, and a front-end reporting *why* a -title failed had no way to recover the code from it — the typed `Error` is -gone by then and only the `E<code>` string prefix survives. Parsing that -prefix is `error_code`'s job; every consumer re-implementing the parse is how -the string-matching this crate spent 1.5.x removing comes back. - -`From<Error> for io::Error` boxes the typed value as the payload -(`io::Error::new(kind, e)`), whose `Display` is the same `E<code>[: …]` -string the stringifying version produced — so `error_code`'s parse is -unaffected, and `From<io::Error> for Error` can additionally `downcast` the -payload back to the exact typed error. Errors that did NOT come from this -crate carry no `E<code>` prefix and yield `None`. - -## is_skippable_title_stub - -This replaces the CLI's `E7023`/`E6008` string-match with a typed check on -the `io::Error` `mux_stream` returns. - -`Error::MkvSourceInvalid`, `Error::MkvLacingInvalid`, `Error::MkvUnencodable` -all used to be raised as `Error::MkvInvalid` and therefore landed in the -skippable set, so a corrupt source was reported as a title worth silently -passing over by a run that then exited successfully. They now carry their -own codes and are fatal, as is `Error::MuxHeaderBufferExceeded`. - -`Error::CssNoDiscKey` (the disc's CSS crack recovered no key at all) is the -same conflation on the decrypt axis: while it too was raised as -`Error::CssKeyMissing`, every title of an undecryptable disc classified as a -skippable stub, so the rip loop skipped all of them and exited successfully. -It belongs to `is_disc_level_no_key` instead, and is fatal here. - -## is_disc_level_no_key - -`E_CSS_NO_DISC_KEY` is the CSS side of the disc-level/per-title split, and it -exists because the disc-wide CSS failure used to be raised as the per-title -`E_CSS_KEY_MISSING`: an undecryptable CSS disc landed in -`is_skippable_title_stub`, so the rip loop skipped all N titles with an -"empty stub" notice and exited successfully. - -The key-SOURCE failures (`E_KEY_SERVICE_UNAVAILABLE`, -`E_KEY_SERVICE_UNAUTHORIZED`, `E_KEY_SERVICE_RATE_LIMITED`) are classified -here for the same fail-fast reason and NOT because they mean "no key": a -service that is down, refusing the token, or throttling is down for every -title on the disc, so iterating N titles re-issues N doomed requests (and, -on 429, digs the rate-limit hole deeper). They are separate CODES precisely -so the front-end can say "retry later" / "fix the token" instead of -`E_NO_DISC_KEY`'s "no key source has a key for this disc". - -## is_marginal_read - -- MEDIUM ERROR (sense key 3) — canonical bad-sector signal. -- ABORTED COMMAND (sense key B) — transient; retry usually works. -- NOT READY (sense key 2) — the dominant bad-sector response on the BU40N - (ASC 0x04/ASCQ 0x3E); a pause + retry often recovers. -- RECOVERED ERROR (sense key 1) / NO SENSE (sense key 0) — not classified as - fatal; treat as recoverable. - -Caller-agnostic predicate — describes a property of the *error*, not what -one specific call site should do with it. Used by -`freemkv_engine::recovery::copy`'s hysteresis dispatch. - -## Test rationale: declared_error_codes / uniqueness - -The uniqueness test used to carry a hand-maintained `vec![]` of the error -constants, with a doc comment claiming it "pins all code assignments". It did -not. At the time this was written the file declared 127 `pub const E_*` and -the vector named 109 of them: eighteen codes — `E_DIR_IMAGE_FANOUT`, -`E_DIR_INSUFFICIENT_SPACE`, `E_DIR_MULTIPASS_REJECTED`, `E_DIR_NAME_COLLISION`, -`E_DIR_NAME_TOO_LONG`, `E_DIR_NOT_EMPTY`, `E_DIR_RAW_REJECTED`, -`E_DIR_SOURCE_UNSUPPORTED`, `E_DIR_WRITE_FAILED`, `E_DRIVE_INQUIRY_SHORT`, -`E_EMPTY_IMAGE`, `E_MP4_UNKNOWN_RESOLUTION`, `E_SEAM_PLAN_DROPPED_MOST`, -`E_SHORT_IMAGE_READ`, `E_SINK_WROTE_NOTHING`, `E_SOURCE_TERMINATED`, -`E_SYNC_TIMEOUT`, `E_SYNC_WORKER_LOST` — were outside the guarantee entirely, -so a new variant colliding with any of them passed green. Worse, an earlier -audit READ that doc comment and trusted it while assigning new codes. - -The defect is not the eighteen omissions, it is that the list is -hand-maintained at all: adding a constant and forgetting the vector is a -silent no-op, which is the definition of a guarantee that decays. Deriving -the list from the declarations makes forgetting impossible — the only way to -escape the check is to stop declaring the constant. `include_str!` of the -same file is the cheapest seam that does that: no `build.rs`, no proc macro, -no dependency, and — being `#[cfg(test)]` — not one byte of the source -embedded in a release build. diff --git a/docs/extract.md b/docs/extract.md deleted file mode 100644 index 8945b9cd..00000000 --- a/docs/extract.md +++ /dev/null @@ -1,150 +0,0 @@ -# `src/disc/extract.rs` — decrypted file-tree extraction - -## Relation to the ISO sweep/patch recovery passes - -`extract_tree` is a sibling of the disc→ISO sector dump (the sweep/patch -recovery passes, which now live in the `freemkv-engine` crate), specialized -to write **per file** rather than a whole image, applying decryption on the -way out, and **without** any multipass / recovery orchestration — it is -1-shot, decrypt-only. - -No new error codes are introduced: it reuses the existing `Error` set -(`error.rs` is numeric-only, no English). - -## `extract_tree` contract details - -`reader` is consumed for content reads (it is moved into a -`DecryptingSectorSource`); the structure + extent metadata are read first -with the raw reader. `dest` receives the tree STRAIGHT IN (no auto-named -subfolder). The caller must have run the pre-flight decrypt gate -(`Disc::ensure_decryptable`); this method resolves per-VTS CSS keys itself -for DVD. - -Bad sectors become recorded zero-filled holes in the affected file (the run -does not abort); undecryptable units are counted as loss. Files are written -`<name>.partial` and renamed on success, so an interrupted run never leaves -a half-written file that looks complete. - -## `resolve_vts_key` — why a failed crack must be a hard error - -FALLIBLE, because the three outcomes of a crack are not two. -[`crate::css::CrackOutcome`] exists precisely to separate "no scrambled -sector was seen, so there is nothing to decrypt" from "scrambled sectors -were seen and no key came out", and its doc says callers MUST surface the -second as a hard error. An earlier version of this function used the -`Option`-returning `crack_key`, which collapses both into `None`, and then -fell back to the disc-wide key — descrambling this VTS with ANOTHER VTS's -title key. The hazard is described in the ordering note in the function -body, which was written about the same fallback; ordering makes the crack -far more likely to succeed, but it cannot make a failed crack safe. - -## `Borrowed` — why a borrowing `SectorSource` wrapper - -Lets the decrypting decorator "own" an inner source for its lifetime while -the caller keeps the underlying `&mut dyn SectorSource` (the decorator is a -`DecryptingSectorSource<S>` generic over `S`, so it does NOT require a -`'static` boxed inner — unlike the mux highway, which takes the reader by -value). The decorator is dropped before `extract_tree` returns, so the -borrow never escapes. - -## `whole_unit_batch` — the trailing-partial rule - -`remaining` is the sectors left in the current extent; the batch is capped -at `READ_BATCH_SECTORS` and rounded DOWN to a whole number of 3-sector units -UNLESS it is the extent's final (possibly short) tail — the tail always -begins on a unit boundary, so a 1-2 sector partial there is handled by -`decrypt_sectors`' trailing-partial contract. Used by `extract_one_file` so -this rounding rule lives in exactly one place. - -## `available_space` (windows) — why the free-space gate matters - -Windows has no `statvfs`. This once returned `None` unconditionally, which -does not merely skip a test — it skipped the free-space GATE, so a Windows -user extracting a disc to a full volume got a confusing failure part-way -through instead of a clear refusal up front. libfreemkv ships a Windows -GUI, so that is the platform where the friendly error matters most. -`FreeBytesAvailableToCaller` (not `TotalNumberOfFreeBytes`) is used because -it accounts for per-user quotas, matching what `statvfs`'s `f_bavail` gives -on the unix side. - -## `sanitize_component` — why reserved names are substituted, not rejected - -A name whose base matches a Windows reserved device name (`NUL`, `CON`, -`COM1`..) is **substituted** (prefixed with `_`) rather than rejected — a -Linux-authored disc may legally carry such a file and a single reserved -name must not abort the whole tree walk. An empty result after stripping -is an error. - -## `names_differing_only_by_case_are_a_collision` — why this must be an error - -Two disc names differing only by CASE are one host file on macOS APFS and -Windows NTFS, both case-insensitive by default. Keyed by the case-preserving -path, the collision map would see two entries, raise nothing, and the -second file extracted would overwrite the first — while both `PlannedFile`s -report `complete: true`. Silent data loss reported as a clean extract is -the one outcome this crate must never produce, so the host-equivalence key -has to model the host's namespace, not the disc's. - -## `a_files_partial_path_colliding_with_another_files_final_name_is_an_error` - -A disc carrying both `X` and `X.partial` plans two distinct final names, so -nothing collides in the final-name check — but extracting `X` writes -through `X.partial`, the same host path the other file owns. Whichever -lands second truncates the other, and both entries would still be reported -complete without this guard. - -## `multi_extent_aacs_anchors_unit_base_per_extent` — the alignment bug - -Regression (rc.6 audit, finding #449): a MULTI-EXTENT AACS file must -re-anchor the unit-alignment base PER extent, not once at the first extent. -The two extents in this test start at absolute LBAs whose difference is NOT -a multiple of 3 (`PART_START+5000` vs `PART_START+5004` — a 4-sector delta). -With a single first-extent unit base the second extent's first read is off -the unit grid (offset 4, `4 % 3 == 1`) → `is_unit_aligned` fails → -`DecryptFailed` → the whole extent becomes a zero-filled (false) hole. With -per-extent anchoring both extents read clean: `bytes_unreadable == 0` and -the file content survives. The fixture content is clear (no TS syncs) so -the decrypt step restores each unit verbatim — this isolates the GATE from -the cipher math. - -## `extract_tree_zero_fills_an_unrecorded_extent_instead_of_reading_it` - -An ECMA-167 4/14.14.1.1 type-1 extent is ALLOCATED BUT NOT RECORDED: the -space belongs to the file and occupies its byte range, but nothing was ever -written there, and the standard defines its contents as zeros. -`read_icb_extents` keeps the flag (`IcbExtent::recorded`) and -`read_file_limited` honours it by emitting zeros WITHOUT touching the -media. The tree extractor reads the same ICBs and must agree: reading those -sectors returns whatever the media happens to hold there — on an AACS disc, -ciphertext that decrypts to noise — and writes it into the extracted file -as if the disc had recorded it. - -## `per_extent_base_is_aligned_first_extent_base_is_not` - -Focused alignment-computation check underpinning the per-extent fix: when -each extent anchors its OWN start as the unit base, the extent's own batch -starts are always unit-aligned; anchoring a later extent against the FIRST -extent's base mis-aligns whenever the extents' starts differ by a -non-multiple of 3 sectors. This is the exact arithmetic the decrypt-on-read -gate (`aacs::content::is_unit_aligned`) performs. - -## `css_two_vts_groups_do_not_cross_contaminate_keys` - -Regression: the per-VTS key crack in `resolve_vts_key` must gather ONLY -this VTS's own title-VOB extents. Two VTS groups here carry DISTINCT -scrambling keys; if the group filter (`vts_group_of(..) == Some(vts) && -is_title_vob(..)`) is loosened (`!=`, or `&&` -> `||`), one VTS's resolve -gathers the OTHER VTS's extents, cracks the wrong key, and that VTS's own -content silently fails to descramble to the expected plaintext. - -## `a_scrambled_vts_that_cannot_be_cracked_fails_instead_of_borrowing_a_key` - -A VTS that IS scrambled but whose key could not be recovered must FAIL, not -borrow another VTS's key. `crack_key` returns `Option`, which collapses "no -scrambled sector was seen" with "scrambled sectors were seen and no key -came out"; the fallback then descrambled this VTS under the disc-wide key -and `extract_tree` reported `complete = true` at exit 0, because no read -had failed. `CrackOutcome` exists to keep those two apart, and every -sibling path in the crate already honours it. The fixture: VTS_01 is -crackable, VTS_02 is scrambled with NO periodic crib, so its crack -genuinely fails. diff --git a/docs/ffmpeg-validation.md b/docs/ffmpeg-validation.md deleted file mode 100644 index 4a9f2638..00000000 --- a/docs/ffmpeg-validation.md +++ /dev/null @@ -1,68 +0,0 @@ -# FFmpeg QA validation - -`.github/workflows/ffmpeg-validation.yml` is a reusable workflow called by -`qa.yml` on pushes to `qa`. It also supports manual runs on a side branch. -Normal development CI runs the Rust tests without installing FFmpeg. - -The QA job installs Ubuntu's FFmpeg package, reads the crate's MSRV, and runs: - -```sh -cargo test --release --lib ffmpeg_ -- --ignored --nocapture -``` - -All fixtures are synthetic and generated during the run: small test patterns, -sine waves, and a tiny PGS bitmap constructed in Rust. No ripped films or -external media corpus are downloaded. Set `FREEMKV_FFMPEG_ARTIFACT_DIR` locally -to retain fixtures. CI uploads generated media, commands, decoder/probe output, -hashes, tool versions and the test log for 14 days, including on failure. - -## Coverage - -- PGS visible/empty compositions at 180/183 and 3780/3783 seconds; decoded - clears and warning-free FFmpeg stream-copy remuxing. -- H.264/AC-3, HEVC/E-AC-3 and MPEG-2 transport-to-MKV through the public mux - driver, including reordered video pictures and delayed audio starts. -- AAC, MP2 and E-AC-3 transport audio discovery and remuxing. -- Fragmented AAC, MP2, AC-3 and E-AC-3 elementary streams: 137-byte input - chunks deliberately split headers and audio frames. -- MKV roundtrips with variable frame cadence, multiple audio tracks, - CodecDelay and DiscardPadding. - -Audio/video tests compare stream metadata, decoded frame counts, all decoded -presentation timestamps with a shared origin shift (2 ms tolerance), and exact -SHA-256 hashes of decoded pixels/PCM. Generated output must decode without -warnings or errors. Hashes compare input/output in the same FFmpeg run rather -than a fixed encoder-version-dependent golden file. - -The gate exposed and now guards fixes for lost PGS clears (also reproducing -issue #52's timestamp warnings), PES-granular AAC/MP2 parsing, missing generic -transport audio types, audio lost before a buffered MPEG-2 opening picture, -and dropped MKV decoder-delay/end-padding metadata. Pure Rust regressions run -in development CI; external decoder checks run explicitly in QA. - -The gate is bounded, synthetic interoperability coverage. It does not replace -real-disc QA, player testing, or cover every codec/profile. Free-format MPEG -audio retains PES-granular handling. Container timing/padding preservation is -currently local MKV-to-MKV; existing network/stdio PES serialization does not -carry the added metadata. Rust callers constructing `PesFrame` literals must -initialize the new `discard_padding_ns` field to zero unless preserving trimming. - -## FFmpeg licensing - -Reviewed against [FFmpeg's legal guidance](https://ffmpeg.org/legal.html) and -[the GPLv2 text distributed by FFmpeg](https://raw.githubusercontent.com/FFmpeg/FFmpeg/master/COPYING.GPLv2), -section 0. FFmpeg is LGPL-2.1-or-later, with optional components that make a -build GPL; Ubuntu's software encoders can include those components. GPLv2 -permits running the executable. Its output is not automatically GPL-covered -merely because FFmpeg produced it. - -This workflow invokes separately installed `ffmpeg`/`ffprobe` executables. -It does not link FFmpeg libraries into libfreemkv, copy FFmpeg implementation -code, modify FFmpeg, bundle its binaries, or upload those binaries as artifacts. -Our generated media and diagnostic logs do not embed FFmpeg implementation -code. On that basis, this CI use does not trigger FFmpeg redistribution or -library-linking obligations for libfreemkv. Reassess if packaging/linking changes. - -This is a software-license assessment, not a guarantee of patent clearance. -Codec patents are separate from FFmpeg's copyright licenses and depend on -jurisdiction and use; FFmpeg's legal page explicitly distinguishes them. diff --git a/docs/file-sector-source-linux.md b/docs/file-sector-source-linux.md deleted file mode 100644 index fe11043d..00000000 --- a/docs/file-sector-source-linux.md +++ /dev/null @@ -1,36 +0,0 @@ -# `file_sector_source::linux` — read-side platform hints - -## Why both `hint_sequential` and `drop_window` - -`POSIX_FADV_SEQUENTIAL` at open widens the kernel's readahead window so -each pread aggregates into fewer NFS round-trips. `DONTNEED` on the -consumed window (called periodically by the caller) drops the -already-read pages from the page cache so an 85 GB streaming ISO read -doesn't fill memory and starve concurrent writes (the MKV output -during mux). Together they mirror the write-side WritebackPipeline's -policy. - -## History - -Pre-Phase-1 (0.20.7 baseline) had both. Phase 1's introduction of -`FileSectorSource` silently dropped the read-side DONTNEED, and -0.21.2's revert of `SEQUENTIAL` (mistakenly attributing a regression -to it) removed the hint. Net effect: 85 GB of ISO reads pinned in the -page cache + no readahead widening → mux throughput collapse from -18 MB/s historical to 2.7-8 MB/s on 0.21.x. Restored in 0.21.6. - -## `prefetch` - -Async-prefetch `len` bytes at `offset` into the page cache. The kernel -`readahead(2)` syscall queues the I/O and returns immediately — it -does NOT wait for completion. Called right after each consumed read -so the next batch's I/O overlaps with the caller's processing of the -current batch (decrypt + demux + mux). - -Without this hint, with a synchronous demux consumer running at -~50 MB/s and a single-spindle disk capable of ~150 MB/s, the disk sits -idle ~70% of each iteration because kernel readahead alone (capped at -`/sys/block/<dev>/queue/read_ahead_kb`, default 128 KB) can only -pre-stage a tiny slice of the next batch. An explicit `readahead()` of -the same size as the current batch tells the kernel to queue the full -next-batch read now. diff --git a/docs/file-sector-source-macos.md b/docs/file-sector-source-macos.md deleted file mode 100644 index 05e644de..00000000 --- a/docs/file-sector-source-macos.md +++ /dev/null @@ -1,29 +0,0 @@ -# file_sector_source::macos - -macOS has no direct `POSIX_FADV_SEQUENTIAL` equivalent; the idiomatic hint is -`fcntl(F_RDADVISE, &radvisory)` describing the byte range you intend to read -soon. The open-time sequential hint points it at the whole file (clamped to -`RDADVISE_MAX_BYTES` so a multi-TB ISO doesn't ask the kernel to prefetch -everything at once). - -## RDADVISE_MAX_BYTES - -Cap on the byte length passed to `F_RDADVISE`. Asking for a multi-GB -readahead window is counterproductive — the OS doesn't have that much cache -to throw at one fd. 64 MiB is generous for our use case (sweep, mux) so the -kernel's prefetch keeps pace with our app-level pipeline depth. - -## drop_window - -macOS has no direct `POSIX_FADV_DONTNEED` equivalent for a byte range. -`fcntl(F_NOCACHE)` would disable caching globally on the fd (too coarse — we -want the unread region to still benefit). Best approximation: no-op. -macOS's unified buffer cache is generally less prone to the pin-everything -pathology that triggers the regression on Linux NFS clients. - -## prefetch - -Async-prefetch the byte range `[offset, offset+len)`. macOS uses the same -`fcntl(F_RDADVISE, &radvisory)` primitive as the open-time sequential hint, -just targeted at a moving window instead of the whole file. The kernel -queues I/O for the requested range and returns immediately. diff --git a/docs/file-sector-source.md b/docs/file-sector-source.md deleted file mode 100644 index bdb0277a..00000000 --- a/docs/file-sector-source.md +++ /dev/null @@ -1,77 +0,0 @@ -# `FileSectorSource` design rationale - -`FileSectorSource` reads 2048-byte sectors from an ISO file on disk via -direct `seek + read_exact` (`pread`-equivalent) calls, letting the kernel's -own readahead policy manage prefetch. - -## Why no app-level buffer - -Pre-0.21.3 this source held a 32 MiB (later 4 MiB) read-ahead buffer to -amortise per-sector NFS round-trips. Empirically that buffer hurt: 32 MiB -refills bursted the NFS TCP connection hard enough to starve the concurrent -writer, and even a 4 MiB window gave the kernel less freedom to pipeline -reads with writes. Direct pread per call lets Linux's readahead widen as it -detects the sequential pattern, and naturally interleaves with writeback. - -## DONTNEED on the consumed window - -Without page-cache eviction an 85 GB streaming ISO read pins the entire -file in memory, starves the concurrent writer, and collapses mux throughput -(observed: 2.7 MB/s mux on 0.21.5 vs. 70 MB/s isolated NFS reads). Every -`READ_DROP_CHUNK_BYTES_DEFAULT` of consumed bytes we call -`posix_fadvise(DONTNEED)` over that window, mirroring the write-side -`crate::io::writeback::WritebackPipeline` policy. - -The drop window is accounted by a monotonic forward byte counter, which -matches the sequential streaming pattern the mux highway drives. Under -random or backward access the dropped range no longer lines up with the -bytes actually read — but `DONTNEED` is purely an advisory cache hint with -no correctness impact, so this degrades to a slightly imprecise hint rather -than a bug. - -`READ_DROP_CHUNK_BYTES_DEFAULT` (32 MiB) is the empirically tuned value on -a 7200rpm HDD via SATA: smaller windows (8 / 16 MiB) shorten the -kernel-readahead overlap and slow the producer; larger windows (64 / 128 -MiB) let the page cache pin enough of the ISO to pressure concurrent -writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`. - -`READ_DROP_CHUNK_MIB_MAX` (64 GiB) bounds `FREEMKV_READ_DROP_CHUNK_MIB` -before the MiB→byte multiply, mirroring `WRITEBACK_CHUNK_MIB_MAX`, whose -identical multiply is bounded for exactly this reason: without the bound, -a value above 2^44 overflows — a panic on the first ISO open in an -overflow-checked build, and in release a wrap to a near-zero window that -fires `drop_window` on every read. Out-of-range values fall back to the -default. - -## Platform open hint - -On `open()` each platform issues its "sequential access expected" hint so -OS-level readahead widens. The hint and the DONTNEED call live in per-OS -sibling modules (`linux::hint_sequential` et al.) — no inline `#[cfg]` in -this file. - -## Read-ahead prefetch - -After every consumed read we issue an OS-level prefetch hint for the next -equivalent-sized window (`platform::prefetch`). The kernel queues that I/O -asynchronously and returns immediately, so the next batch's read overlaps -with the caller's processing of the current batch (decrypt + demux + mux). -Without this the disk sits idle ~70% of each iteration because kernel -SEQUENTIAL readahead alone (capped at `read_ahead_kb`, default 128 KB) is -far smaller than our 16 MiB app-level batch. - -## Test notes - -- `read_sectors_with_an_undersized_buffer_errors_rather_than_panicking`: - this is a public `SectorSource` impl, so buffer length is caller input, - and the guard used to be a `debug_assert!` — compiled out in release, - where the `out[..bytes]` slice then panicked with 'range end index out - of range'. `Drive::read_fua` already carries this exact guard with a - comment recording the same panic being fixed there, and - `PrefetchedSectorSource` has `direct_read_too_small_buffer_errors` for - the same case; this impl had neither. -- `dontneed_eviction_does_not_affect_data`: reads past the default 32 MiB - drop chunk (16384 sectors) so the eviction block fires at least once, - asserting every sector still reads correctly. Avoids mutating - `FREEMKV_READ_DROP_CHUNK_MIB` to sidestep a parallel-test env race with - `drop_chunk_size_env_override`. diff --git a/docs/flac.md b/docs/flac.md deleted file mode 100644 index 2d1d9d4c..00000000 --- a/docs/flac.md +++ /dev/null @@ -1,16 +0,0 @@ -# FLAC elementary-stream decodability gate (`src/mux/codec/flac.rs`) - -FLAC frames carry no length field, so a raw stream is delimited only by -sync-scanning + CRC validation. In freemkv, though, FLAC never arrives raw: -it comes from mp4/mkv, where each packet is exactly one container-delimited -FLAC frame (a complete, pre-delimited frame per packet). So this parser -is a per-packet gate, not a framer: every FLAC frame ends with a 16-bit CRC -(poly 0x8005, init 0, non-reflected) computed so the residue over the whole -frame — footer CRC included — is zero (per the FLAC format specification, -RFC 9639, frame footer). A nonzero residue is definitive corruption → drop -the frame (a silence gap, never a shift — each packet keeps its own PTS), -logged via the shared tally. - -A packet that does not begin with the FLAC frame sync is not a delimited -frame we can validate, so it is passed through unchanged (never -false-dropped). diff --git a/docs/fmp4-mod.md b/docs/fmp4-mod.md deleted file mode 100644 index f0ef4179..00000000 --- a/docs/fmp4-mod.md +++ /dev/null @@ -1,36 +0,0 @@ -# fMP4 muxer (`src/mux/fmp4/mod.rs`) — stub status and design notes - -## Status - -**STUB**. The muxer can emit the init segment (`ftyp` + a minimal HEVC -`moov` skeleton with one video track) via -[`Fmp4Mux::write_init_segment`], so the shape and call site are -validated, but media fragments (`moof`/`mdat`) are NOT emitted. -[`Fmp4Mux::write_video`] therefore returns -[`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented) -rather than silently accepting and discarding frames. It buffers -nothing, so it cannot accumulate memory. - -## Not yet implemented - -- `moof` box: `mfhd` (sequence_number) + `traf` (`tfhd` + `tfdt` - + `trun` with sample sizes, durations, flags, composition offsets). -- `mdat` box: concatenated sample data. -- Fragment cadence: one fragment per GOP or every N seconds, - whichever comes first. -- HEVC `hvcC` box inside `moov.trak.mdia.minf.stbl.stsd` so the - init segment is self-describing (`stsd` currently has zero entries). -- Sample-flags computation (sync vs. delta, depends_on, etc.). -- Edit lists / fragment_duration for accurate seeking. - -Reference: ISO/IEC 14496-12 §8 (Movie Fragments). - -## `wrap_box` size-cast rationale - -All callers build tiny init-segment boxes (kilobytes at most), so the -`u32` size never overflows; the saturating cast plus the debug assert -documents and guards that invariant rather than silently emitting a -truncated, structurally corrupt size field. `body` is always -internally constructed here, never untrusted input — a future caller -feeding a multi-gigabyte body trips the debug assert instead of -writing a malformed box. diff --git a/docs/fox.md b/docs/fox.md deleted file mode 100644 index 655a3c9f..00000000 --- a/docs/fox.md +++ /dev/null @@ -1,70 +0,0 @@ -# Fox — `dcx.xml` label parser - -Older Fox authoring (e.g. a real Fox release, `/BDMV/JAR/05001/dcx.xml`) ships a -human-readable XML manifest alongside the BD-J jar. Its root is `<dcx>`, and -under `<disc>` it lists every playlist the disc plays. The main-feature -playlists carry nested per-stream `<audio>`/`<subtitle>` elements naming -language, editorial purpose and forced/SDH state outright — everything a -label parser wants, in attributes, with no bytecode to walk. - -NOT A SPECIFICATION. `/BDMV/JAR/` is application-defined space, so this file -is one authoring house's internal metadata that happens to press onto the -disc. Every field meaning below was read off a real disc; treat an -unfamiliar value as unknown rather than guessing. - -## Confirmed schema - -A real Fox release, `/BDMV/JAR/05001/dcx.xml`: - -```xml -<dcx> - <disc> - <playlist id="00001" lang="eng" name="topmenu"/> - ... - <playlist id="00800" lang="eng" name="feature" vers="1" durs="7628"> - <audio id="01" lang="eng" type="feature"/> - <audio id="02" lang="eng" type="rnib"/> - <audio id="03" lang="spa" dial="lat" type="feature"/> - ... - <subtitle id="01" lang="eng" type="feature" form="sdh"/> - <subtitle id="02" lang="spa" dial="lat" type="embed"/> - ... - <subtitle id="11" lang="eng" type="text"/> - <properties> ...chapter marks... </properties> - </playlist> - <playlist id="00801" lang="jpn" name="feature" vers="1" durs="7628"> ... </playlist> - </disc> -</dcx> -``` - -## Field meanings (read off the disc, not documented) - -* `<audio type>`: `feature` = a normal program track; `rnib` = a - descriptive/narration track ("Royal National Institute of Blind People" - described-video), mapped to `LabelPurpose::Descriptive`. A `type` - containing "comment" maps to `LabelPurpose::Commentary`. -* `<subtitle form>`: `sdh` marks a subtitles-for-the-deaf-and-hard-of- - hearing track (`LabelQualifier::Sdh`). -* `<subtitle type>`: `embed` marks a dedicated forced/embedded-subtitle - track (`LabelQualifier::Forced`); `feature`/`text` are full tracks. -* `id` on a nested stream is its 1-based STN slot WITHIN ITS TYPE - (audio ids 01..N, subtitle ids 01..N independently), matching the vendor - `stream_number` convention the ordinal binder in `super::apply_labels` - reads. - -The `<audio>`/`<subtitle>` elements are NESTED inside one feature playlist, -so extraction is scoped to a single `<playlist name="feature">` element — -never a document-wide `<audio>` scan, which would merge the regional -`00800` (eng) and `00801` (jpn) tables into one and collide their slots. - -## `select_feature_playlist` scoping rationale - -Among all `<playlist name="feature">` elements — Fox presses one per -regional variant (`00800` eng, `00801` jpn) — the one carrying the most -`<audio>`/`<subtitle>` streams wins, ties to the first. - -Returning ONE element is the whole point: each feature playlist is its own -STN table with its own 1-based slot ids, so merging two would put two -different streams on slot `01`. The richest table is the fullest label set -and anchors to its matching title by language sequence in -`super::apply_labels`. diff --git a/docs/fs-type-linux.md b/docs/fs-type-linux.md deleted file mode 100644 index edb9dc12..00000000 --- a/docs/fs-type-linux.md +++ /dev/null @@ -1,13 +0,0 @@ -# Linux `f_type` classification - -`classify_f_type` in `src/platform/fs_type/linux.rs` is the single source of -truth for the magic-number comparisons used by both the path-based -(`detect_impl`) and fd-based (`detect_fd_impl`) entry points. - -## Why the `i64` cast - -`statfs::f_type` is signed `__fsword_t` on glibc and unsigned `c_ulong` on -musl; a portable comparison needs a common type, so the value is cast to -`i64`. On glibc x86_64 both are already `i64` — clippy flags the cast as -unnecessary on that target only, but the cast is required for musl, hence -the `#[allow(clippy::unnecessary_cast)]`. diff --git a/docs/fs-type.md b/docs/fs-type.md deleted file mode 100644 index e25d6e6a..00000000 --- a/docs/fs-type.md +++ /dev/null @@ -1,12 +0,0 @@ -# fs_type module - -The buffering architecture (Phase 2) selects different output sinks -for local vs network filesystems: NFS gets the adaptive -`WritebackFile` machinery on Linux; local disks get `LocalFileSink` -and rely on the kernel's default writeback policy. This module -provides the construction-site primitive that picks which one. - -Per the per-OS file-split convention, the actual `statfs` call lives -in the matching platform file (`linux.rs`, `macos.rs`, `windows.rs`, -`other.rs`); this `mod.rs` exposes only the cross-platform enum and -the `detect` entry point. diff --git a/docs/fsync.md b/docs/fsync.md deleted file mode 100644 index f08766ba..00000000 --- a/docs/fsync.md +++ /dev/null @@ -1,20 +0,0 @@ -# `io::fsync` — platform-aware crash-durability primitives - -Two flush operations need OS-specific handling to make a write survive a -crash / power loss: - -- **`dir`** — fsync a directory so a prior `rename(2)` into it is durable. - After a crash a renamed file's dirent can otherwise be lost even though - the rename returned, because it is still page-cache-only. This is a POSIX - concept: on Windows std cannot even open a directory as a `File` (it does - not set `FILE_FLAG_BACKUP_SEMANTICS`), and NTFS/ReFS commit the rename's - dirent without an explicit directory flush — so it is a no-op there - rather than a failed open that logs on every marker write. - -- **`file_durable`** — fsync a file's contents + metadata. Opens the file - **read+write**: on Windows `File::sync_all` maps to `FlushFileBuffers`, - which requires a handle with write access and returns - `ERROR_ACCESS_DENIED` (os error 5) on a read-only handle. (A read-only - `File::open` + `sync_all` is legal on POSIX, which is why that bug only - bit Windows.) The open mode is platform-uniform, so this lives here with - no dispatch. diff --git a/docs/fvi-sink.md b/docs/fvi-sink.md deleted file mode 100644 index 48fdf2b9..00000000 --- a/docs/fvi-sink.md +++ /dev/null @@ -1,37 +0,0 @@ -# `fvi://` sink notes - -Rationale and design notes for `src/mux/fvi_sink.rs` that don't belong in the -public rustdoc contract. - -## Relationship to `VideoMap` - -The sink is a thin consumer of the reusable, pure-data -[`VideoMap`](crate::mux::videomap) model -([`MapHeader`](crate::mux::videomap::MapHeader) / -[`PictureRecord`](crate::mux::videomap::PictureRecord)): it builds the header -from the title, then writes one record per video -[`PesFrame`](crate::pes::PesFrame) straight to disk. Nothing here re-parses -the elementary stream, and the whole index is never buffered. - -Serialization is inlined in the sink itself. - -## Extensibility - -A different output format would be a DIFFERENT sink reusing the same -`VideoMap` model (e.g. a future `fvi2://`), not a pluggable encoder — -extensibility is by adding a sink, like every other sink in this crate. - -## `write_fvi_record`: coding-derived members (§7.1) - -Coding-derived members (§7.1) are produced via the codec-agnostic -`PictureInfo` accessors, never the raw bitstream: `field_order` is emitted -only when measured (OMITTED on codec-type-only HEVC/H.264/VC-1), -`progressive` only when signalled. - -## `write_fvi_record`: `dts` / future `recovered` member - -`dts` is MAY per the format spec — the highway carries no DTS on a frame, so -it is always omitted. - -TODO(provenance→recovery join): a `recovered` MAY member (sweep/patch mapfile -overlap with this AU's `src`) belongs here; unreachable at `PesFrame`. diff --git a/docs/h264.md b/docs/h264.md deleted file mode 100644 index 8e630731..00000000 --- a/docs/h264.md +++ /dev/null @@ -1,189 +0,0 @@ -# H.264 codec parser notes - -## Slice-type mapping - -`h264_slice_coding_type` maps an H.264 `slice_type` (Rec. ITU-T H.264 §7.4.3, -Table 7-6) to a coding type. Values 5..=9 repeat 0..=4 (the "all slices of -this type" forms), so `slice_type % 5`: 0 = P, 1 = B, 2 = I, 3 = SP -(predicted → P), 4 = SI (intra → I). Returns `None` for values outside 0..=9 -(malformed header). - -## `unescape_ebsp_prefix` - -Copies the leading bytes of an EBSP with emulation-prevention bytes removed -(ISO/IEC 14496-10 §7.3.1: a 0x03 following 0x00 0x00 is not part of the -RBSP). Only a short prefix is produced, because the sole caller decodes two -ue(v) fields from the very start of a slice header. `first_mb_in_slice` and -`slice_type` are each at most 32 bits, so 16 octets is ample and keeps this -off the per-frame allocation path for anything larger. - -## `unescape_ebsp` - -Copies `ebsp` with emulation-prevention bytes removed, stopping after at most -`max_octets` output bytes (pass `ebsp.len()` for "no cap"). - -The zero run-length counter is cumulative across the whole scan and is reset -only when an escape byte is actually dropped — matching the reference -decoding process in ITU-T H.264 §7.3.1, which discards a 0x03 following ANY -run of two-or-more 0x00 bytes, not just an exact `00 00 03` sliding window. A -window-based scanner that advances by 3 on a match and by 1 otherwise -disagrees with this on a run of 3+ real zero bytes ahead of an 0x03: -`00 00 00 03` decodes here as `00 00 00` (the run is escaped, matching every -other H.264 decoder), where a window scan starting fresh after each -non-match would keep the 0x03 as real payload. Only one implementation of -this rule may exist in this module — see `parse_sps_high_profile_ext`, which -used to keep its own window-scan copy that disagreed with this one on -exactly that input. - -## `handle_param_set` - -Handles an SPS/PPS NAL (mirrors the HEVC fix). The strip/emit decision is -made against the currently-ACTIVE set `cur`, NOT the codecPrivate copy -`first`: a streaming MKV decoder applies avcC once at init and thereafter -updates a parameter set only from an in-band NAL, so a switch BACK to the -first-seen body (== codecPrivate) is still a change the decoder must be told -about. Stripping on `== first` silently dropped that revert. - -- First of its type → seeds codecPrivate; stripped (decoder gets it from avcC). -- Equal to the active set `cur` → redundant; stripped. -- Different from `cur` (a change in EITHER direction) → emitted in-band and - `cur` updated. - -Returns `true` when the NAL was emitted in-band into `frame_data`. - -## `reassert_active` - -Appends the active parameter set `cur` to `prefix` (length-prefixed) so -every keyframe is SELF-CONTAINED: it carries the active SPS/PPS in-band -ahead of its slices. Skipped only when this access unit ALREADY carried the -NAL in-band (`emitted` — avoids a duplicate) or no active set exists yet. - -Unconditional (not only when the active set differs from codecPrivate): a -streaming decoder applies the avcC param sets once at init, then relies on -in-band repetition. Some sources stop repeating a param set at later IDRs -even though its body is unchanged; if the decoder then drops it (a reset -event), nothing re-sends it and every subsequent slice fails (param-set id -out of range) until the next genuine change. Re-asserting at EVERY keyframe -— what compliant muxers do at every IDR — makes streaming decode -self-healing. Re-sending an identical param set is benign; cost is a few -bytes per keyframe. This strictly supersets the change-only re-assert, so -the param-set-revert fix is unaffected. - -## `parse_sps_high_profile_ext` - -Parses `(chroma_format_idc, bit_depth_luma_minus8, bit_depth_chroma_minus8)` -from a High-Profile SPS NAL (profile_idc ∈ `HIGH_PROFILES` — the 14 -chroma/bit-depth extended profiles `codec_private` invokes this for). - -SPS RBSP layout (ITU-T H.264 §7.3.2.1.1) up to the fields we need: - -``` -byte 0 NAL header (already known to be type 7) -byte 1 profile_idc -byte 2 constraint_set_flags / reserved -byte 3 level_idc -ue(v) seq_parameter_set_id -— High-profile branch — -ue(v) chroma_format_idc -if chroma_format_idc == 3: u(1) separate_colour_plane_flag -ue(v) bit_depth_luma_minus8 -ue(v) bit_depth_chroma_minus8 -``` - -RBSP emulation-prevention bytes (0x00 0x00 0x03 → 0x00 0x00) are removed -before bit-parsing so the bit reader sees clean RBSP data. - -Returns `None` if the SPS is too short or malformed (Exp-Golomb code -overflows 32 bits, leading-zero count > 31, etc.). The caller silently omits -the extension in that case. - -## Test notes - -### `open_gop_intra_access_unit_is_a_keyframe` - -Open-GOP resync fix: a non-IDR intra-coded access unit MUST be flagged a -keyframe so the B1 resync gate (`mux/resync.rs`) can disarm on a BD -open-GOP tail instead of dropping every frame to EOF and truncating the -video. BD H.264 titles reach random access at a non-IDR I-frame (the -recovery point); HEVC gets this free from the CRA NAL type, H.264 does not, -so the measured intra coding type is the signal. Red before the intra -promotion in `parse` — a non-IDR I-slice used to yield `keyframe == false`. - -### `inter_coded_access_unit_is_not_a_keyframe` - -Guards the promotion's precision: a non-intra (P) access unit must NOT be -promoted to a keyframe, or the resync gate would resume on a frame that -carries inter-references to dropped data and the MKV cue index would gain -bogus random-access points. - -### `slice_header_parse_removes_emulation_prevention_bytes` - -The slice-header parse must remove emulation-prevention bytes first -(ISO/IEC 14496-10 §7.3.1/§7.4.1): the payload after the NAL header is EBSP, -and a 0x03 following 0x00 0x00 is not part of the RBSP. - -Reachable when `first_mb_in_slice` is 65535 or more — a ue(v) that needs -sixteen leading zero bits, so the payload opens 0x00 0x00 and an encoder -must escape it. A UHD frame is ~32,400 macroblocks so conforming Blu-ray -never gets there, but 8K does, and the disc is untrusted input. Reading the -raw NAL decoded `slice_type` against the inserted byte and produced the -wrong picture type. - -### `unescape_ebsp_drops_escape_after_a_run_of_three_zeros` - -Regression: `parse_sps_high_profile_ext` used to re-derive the -emulation-prevention rule with its own window scanner (match `00 00 03` at -position i, advance by 3; else advance by 1) instead of calling the shared -`unescape_ebsp`. On a run of 3+ real 0x00 bytes ahead of an 0x03 — -non-conformant, but this is untrusted disc input, not a spec-clean encoder — -the two disagreed: `unescape_ebsp`'s cumulative zero counter (the rule the -H.264 reference decoding process in §7.3.1 specifies) drops the 0x03 as an -escape, while a fresh 3-byte window starting right after the non-matching -first byte kept it as real payload. Pin the shared function's behaviour -here so a second hand-rolled copy doesn't quietly reappear. - -### `keyframe_param_reassert_emits_exact_bytes` - -The keyframe SPS/PPS re-assert must produce EXACTLY these bytes: the active -SPS, then the active PPS, then the access unit's own NALs, each as a 4-byte -big-endian length prefix followed by the NAL body (ISO/IEC 14496-15 -length-prefixed form, lengthSizeMinusOne = 3). - -This pins the full byte string, not just its length, so the in-place -`splice` that replaced the build-a-new-buffer-and-copy approach is proven -byte-for-byte equivalent — including the ORDER (parameter sets ahead of the -slices, which is what makes the keyframe self-contained). The literals were -captured from the pre-splice implementation's output. - -### `keyframe_param_reassert_does_not_reallocate_the_frame` - -MEASURED: the keyframe parameter-set re-assert must be spliced into the -front of the already-assembled access unit IN PLACE, not built as a fresh -full-size buffer. - -It used to `prefix.extend_from_slice(&frame_data)` and then replace -`frame_data` with `prefix`, which grew a few-hundred-byte `prefix` to the -FULL access-unit size — a fresh multi-hundred-KB allocation — memcpy'd the -whole frame into it, and dropped the presized buffer. One extra whole-frame -allocation plus one extra whole-frame copy per keyframe: a UHD title is -~200,000 frames of 150-400 KB with a keyframe every 1-2 s, i.e. thousands of -avoidable multi-hundred-KB copies per title, each large enough to go through -mmap. `PARAM_REASSERT_HEADROOM` exists so the splice never reallocates; this -counts the reallocations that happen, which must be zero. Mirrors the HEVC -parser's test of the same name. - -### `avcc_profile_244_appends_extension_bytes` - -ISO 14496-15 §5.3.3.1.2 regression: profile_idc=244 (High 4:4:4 Predictive) -ALSO mandates the chroma/bit-depth extension. It was missing from -HIGH_PROFILES, so a 244 stream took the Baseline/Main path and emitted an -avcC with NO extension bytes — non-conforming, and strict parsers then -assume 8-bit 4:2:0. The extension must be appended. - -### `sps_reader_read_ue_thirty_one_leading_zeros_is_still_valid` - -`SpsReader::read_ue`'s truncation guard is `leading_zeros > 31`: 31 leading -zero bits is the longest legal code and must decode, not abort. Mirrors the -equivalent guard in the shared `BitReader` in `startcode.rs`, kept here -because `SpsReader` is a separate, unshared implementation used only for SPS -parsing. diff --git a/docs/halt.md b/docs/halt.md deleted file mode 100644 index 23392367..00000000 --- a/docs/halt.md +++ /dev/null @@ -1,16 +0,0 @@ -# halt - -## Why `Ordering::Relaxed` - -`Ordering::Relaxed` is sufficient on both load and store because this flag -is purely advisory — no other memory operations piggyback on it for -happens-before ordering. Callers that need to publish data across threads -do so via channels or other synchronization, not via this bit. - -## Why 250ms for `POLL_INTERVAL` - -250 ms is the sweet spot between responsiveness (operator presses Stop, -sees it take effect within ~quarter-second) and waste (atomic load + clock -read is cheap but not free at thousands of hertz). It's centralised here -so the half-dozen halt-polling loops across `io` can't drift apart -silently. diff --git a/docs/harness.md b/docs/harness.md deleted file mode 100644 index 31765e75..00000000 --- a/docs/harness.md +++ /dev/null @@ -1,35 +0,0 @@ -# Seeded robustness harness (`src/harness.rs`) - -Every parser reached from here takes bytes that came off a disc, and this -crate's primary boundary is that the disc is untrusted: a malformed, damaged -or hostile image must never crash the library. These tests assert exactly -that one property — the parser returns `Ok` or `Err`, and never panics. - -## Why this exists rather than `cargo-fuzz` - -`cargo-fuzz` needs a nightly toolchain (`-Zsanitizer` plus SanitizerCoverage -for libFuzzer's coverage feedback) and this project pins stable. So the -generator lives here instead. It gives up coverage-guided mutation — the real -loss — and keeps everything else: millions of cases, structure-aware input, -and a crash corpus. It also gains determinism, which a fuzzer does not have: -the same seed replays the same cases on any machine. - -## Why no `proptest` or `arbitrary` - -This crate has exactly one dev-dependency. That is a deliberate posture, and -a randomness crate is not worth ten transitive dependencies when the parsers -take plain `&[u8]` and a good enough generator is forty lines. - -## Budget - -`FREEMKV_HARNESS_CASES` sets cases per generator per target (default 256, low -enough that the per-commit gate stays under a second). The overnight run sets -it to millions. `FREEMKV_HARNESS_SEED` overrides the seed; the default is -fixed so a failure in CI reproduces locally verbatim. - -## On failure - -The panic message carries the seed, generator and case index. Re-run with -`FREEMKV_HARNESS_SEED=<seed>` to reproduce, then write the offending bytes -into `tests/corpus/` as a permanent regression fixture — discovery happens -here, defence happens there. diff --git a/docs/hddvd.md b/docs/hddvd.md deleted file mode 100644 index 289ed232..00000000 --- a/docs/hddvd.md +++ /dev/null @@ -1,465 +0,0 @@ -# HD-DVD title scanning (`src/disc/hddvd.rs`) - -HD-DVD is a **tree-level peer** of DVD and Blu-ray (not a stream variant like -FMTS): its content lives in `HVDVD_TS/` as `.evo` clips — Enhanced VOB, an -MPEG **program** stream — each with a small `.map` timemap sidecar, navigated -by `.xpl`/`.ifo` playlists in `HVDVD_TS/` and `ADV_OBJ/`. Because it is a -different tree with a different playlist format, it gets its OWN scanner -(this file), a peer to `Disc::scan_bluray_titles` — the two-format design -rule: a genuinely different format is a new enumerator, not an extension -bolted into the BD path. - -## Title composition (authoritative path) - -Title composition is authoritative, from the Advanced-Content playlist. HD-DVD -ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum -`HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser, -`roxmltree`) into one `DiscTitle` per `<Title>`: its `<PrimaryAudioVideoClip>` -clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the -`titleDuration`, the `displayName`, and the `<ChapterList>`. A layer-break -split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed -into ONE title with the two parts as clips, carrying each clip's title-time -in/out points (45 kHz ticks) so a seamless join can be spliced onto one -timeline. Container is `ContentFormat::MpegPs`, so the existing PS mux path -handles it. Per-clip streams are enumerated by demuxing the clip head and -building one `Stream` per distinct elementary stream (video + DD+ audio), -codec sniffed from the ES bytes. - -## Fallback path - -When no playlist is present (or it fails to parse), the scanner falls back to -the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the -`feature*`-named clips into one title, and emit every other clip on its own. - -## Known gaps - -Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track -audio languages (the XPL carries `<Audio description=...>` but they are not yet -wired onto the streams). Extents and size are real (the ripper images the -clips). - -## VTI parsing - -`parse_vti_clip_order` parses the `ADVANCED-VTS` VTI clip-name table. The -table is a run of `VTI_CLIP_ENTRY_STRIDE`-spaced records, each carrying a -NUL-terminated `<name>.EVO`. Rather than trust the (imprecise) header pointer, -it collects every NUL-terminated `*.EVO` name and keeps the largest group -sharing one residue modulo the stride — the clip table — in offset order. -Returns empty for a non-VTI blob or one with no recognizable table. - -## Codec sniffing - -`sniff_video_codec` reads MPEG / Annex-B start codes: `00 00 01 B3` → MPEG-2 -(sequence_header), `00 00 01 0F` → VC-1 (BD/HD-DVD sequence-header BDU), -`00 00 01 [x7]` H.264 SPS NAL (type 7, forbidden_zero_bit clear) → H.264. -Returns `None` when no recognizable start code is present. The scan prefers -the unambiguous MPEG-2 / VC-1 sequence headers; H.264 is inferred from an SPS -NAL so a stray slice/picture code can't be mistaken for a different codec. - -`sniff_audio_codec` recognizes only Dolby Digital Plus (E-AC-3) today, via its -`0x0B77` syncword — what real retail HD-DVD titles carry on sub-ids -`0xC0..=0xC7`. Returns `None` for an unrecognized sample so the caller drops -the stream rather than mislabeling it. - -## probe_evo_streams - -Demuxes the head of an `.evo` clip (through the disc's `SectorSource`) and -builds one `Stream` per distinct elementary stream found: the video track -(mapped to the canonical `DVD_VIDEO_PID` for plain 0xE0-0xEF video, or the -extended-stream-id PID for HD-DVD VC-1) and every DD+ audio sub-stream -(mapped via `dvd_audio_pid`). Codec is sniffed from the demuxed ES bytes. - -Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution / -language / channels use sane HD-DVD defaults (the muxer reads the true pixel -dimensions from the H.264 SPS, and E-AC-3 channel counts are not decoded -here). Returns an empty vec when the clip cannot be read or carries no -recognizable stream (e.g. an AACS-encrypted clip probed as ciphertext). - -Cancellation is checked before every chunk read and is the ONE condition that -returns `Err` rather than an empty vec: a probe cut short by a Stop has not -established that the clip carries no streams, and reporting it as if it had -would enumerate a stream-less title as a scanned fact. A read that fails for -any other reason keeps the existing best-effort behaviour. - -## collect_es - -VC-1 video rides extended-stream-id `0xFD` with `stream_id_extension` `0x55`. -HD audio (MLP/TrueHD) can also use `0xFD` with other extensions — routing -those to their own audio tracks is deferred (see the HD-DVD program-chain -follow-up); until then only the VC-1 extension is treated as video, so an -audio `0xFD` sub-stream can never mis-stamp the video PID. - -## XPL amplification bounds - -The XPL parser guards against a crafted `ADV_OBJ/VPLST000.XPL` amplifying a -small file into an enormous amount of work or memory. Four separate axes are -bounded, each independently, because they are separate attacks: - -- **`MAX_XPL_DEPTH` (32)** bounds element NESTING. A real `VPLST000.XPL` is - about six levels deep at its deepest - (`Playlist`/`TitleSet`/`Title`/`PrimaryAudioVideoClip`/`Video`, or - `.../ChapterList/Chapter`); 32 leaves a 5x margin for authoring tools that - wrap extra `ApplicationSegment`/`ObjectMappingList` layers, while staying - trivially stack-safe. - -- **`MAX_XPL_TITLES` (512)** bounds how many `<Title>` elements one playlist - may declare — BREADTH, separate from the depth guard: a flat playlist - passes the depth check trivially and can still declare a title per 51 - bytes, so a 64 MiB `VPLST000.XPL` (exactly `udf::MAX_FILE_BYTES`) reaches - ~1.3 million of them. This matters here and not on the Blu-ray side because - of where the count comes from: `bluray.rs` learns its playlists from the - MPLS files actually present in the UDF directory, so the medium bounds it — - an XPL DECLARES its titles, so nothing does. The cost is drive time, not - memory: `compose_xpl_titles` runs `probe_evo_streams` once per title, and - each probe reads real sectors, so an uncapped count turns a `Disc::scan()` - the operator expects to take seconds into tens of terabytes of optical - reads on a disc that is only pretending to be large. The bound that matters - is `cap x EVO_PROBE_SECTORS`, since `compose_xpl_titles` memoizes on the - title's RESOLVED extent list, and distinct titles built from different - combinations of the same clips have different lists, so each one misses the - memo — at 4096 that product was 64 GiB of optical reads, the same worst - case, and the same contradiction with "seconds", as the sibling - `MAX_HDDVD_CLIPS` path. 512 keeps it at 8 GiB and remains far above any - real disc (retail HD-DVDs carry tens of titles). - -- **`MAX_HDDVD_CLIPS` (512)** caps `.evo` clips taken from the `HVDVD_TS/` - directory listing. `MAX_XPL_TITLES` and `xpl_depth_within_limit` bound the - `ADV_OBJ/*.XPL` path, but a crafted disc simply omits `/ADV_OBJ`; control - then reaches the clip-name fallback in `Disc::scan_hddvd_titles`, which - emits one title per directory entry and probes each — the same unbounded- - probe cost, reached without the playlist. The directory is the only bound - the medium supplies, and `udf::MAX_DIR_BYTES` (1 MiB) still leaves room for - ~24,000 FIDs. Memoizing the probe (`EvoProbeCache`) collapses entries that - resolve to the SAME extents, closing the many-names-one-File-Entry case — - but not this one: a File Entry is a single sector, so an attacker can give - every FID its own, each declaring its own extent, for ~48 MiB of image; - every probe then misses the memo and costs a full `EVO_PROBE_SECTORS` - (16 MiB) read, hundreds of GiB on a scan the operator expects to take - seconds. The cap's job is to bound `cap x EVO_PROBE_SECTORS`; it was 4096 - (matching `MAX_XPL_TITLES`, which bounds a different, no-per-item-read-cost - path) — 4096 x 16 MiB is 64 GiB, more than dual-layer HD-DVD media - physically holds (reachable anyway, since distinct extent lists may overlap - on the medium). 512 keeps the product at 8 GiB and still leaves a wide - margin over any real disc (retail HD-DVDs carry TENS of `.evo` clips, ~10x - the real-world maximum, the sizing convention `MAX_XPL_CHAPTERS_PER_TITLE` - also uses against the 99-chapter authoring ceiling). Pinned by - `the_clip_cap_and_probe_budget_bound_a_scans_worst_case_read_volume`, which - asserts the PRODUCT rather than either constant, so raising one without - re-examining the other fails. - -- **`MAX_XPL_CLIPS_PER_TITLE` (256)** bounds how many - `<PrimaryAudioVideoClip>` elements a SINGLE `<Title>` may contribute — the - fourth amplification axis, the one the other three guards leave open. A - 64 MiB XPL holds ~1.7 million `<PrimaryAudioVideoClip src="A.EVO"/>` - elements at ~38 bytes each, and the collector is `descendants()`, not - `children()`, so a clip nested inside N ancestor `<Title>` elements is - collected by EVERY one of them — the depth cap still permits 32 such - ancestors (measured: a bare `<Title>` root nests 32 deep and still parses, - because the guard counts the self-closing clip element as non-nesting). - The product is ~1.7M x 32 ≈ 56 million heap-allocated `XplClip`s, each with - its own `String`, and then as many `Clip`s again in `compose_xpl_titles`. - Unlike the title cap, the cost here is MEMORY rather than drive time: - `compose_xpl_titles` de-duplicates by `.evo` name before probing - (`seen_evos`), so repeats cost no extra sector reads, but the `Vec`s are - built and held regardless. 256 is far above any real disc — a feature is - one clip, or two across a layer break; even seamless-branching/multi-angle - is tens — and keeps the aggregate bounded at `MAX_XPL_TITLES` x 256 clips - rather than the tens of millions above. - -- **`MAX_XPL_CHAPTERS_PER_TITLE` (1024)** bounds `<Chapter>` elements per - `<Title>` — the same `descendants()` amplification, on the second unbounded - `collect()` in the same loop, bounded separately so capping clips doesn't - simply move the attack next door. A chapter is a bare `f64` rather than a - `String`-carrying struct so it's cheaper, but a - `<Chapter titleTimeBegin="…"/>` element is also smaller, so the element - count an XPL can reach is comparable. 1024 is far above any real disc: the - DVD/HD-DVD authoring convention tops out at 99 chapters per title, a 10x - margin. - -## scan_hddvd_titles - -Scans HD-DVD titles from the `HVDVD_TS/` `.evo` clips. The main feature is -authored as one or more `.evo` clips (a layer-break split — -`FEATURE_1`/`FEATURE_2` or `feature`/`feature_Divide`). The `HVA*.VTI` -navigation file names every clip in authored order; this parses it to -concatenate the feature clips into ONE title (so the largest-title pick gets -the whole movie, not just part 1), emitting every other clip as its own -title. Falls back to one title per clip when the VTI is absent or -unparseable, so a disc with no readable navigation still enumerates. -`chapters`/duration are left empty pending deeper VTI parsing. - -Cancellation: `halt` is polled once per clip and again before every probe -chunk, and a read that fails with `Error::Halted` — how a live drive reports -a Stop, since `Drive::checked_exec` fails every command once its flag is set -— is propagated rather than swallowed. `Halted` is the only error this -returns; every other read failure keeps its best-effort behaviour. It has to -be an error and not a short title list: a cancelled enumeration that -returned `Ok` would be indistinguishable from a disc that genuinely holds -fewer titles, and the caller would cache and act on it. - -## xpl_depth_within_limit - -Rejects a disc-supplied XML blob whose element nesting exceeds -`MAX_XPL_DEPTH`, BEFORE it reaches the parser. `roxmltree` is -recursive-descent and its depth-10 limit applies only to ENTITY expansion, so -element nesting is unbounded: a few hundred KB of well-formed XML (far under -the read cap) overflows the thread stack. That is a process ABORT, not an -`Err` and not an unwind, so neither the `Document::parse` fallback nor -`catch_unwind` can contain it; the only fix is to not hand the document over -at all. A byte-size cap is deliberately NOT added: file size is already -bounded by the UDF read path, and a size cap does not close this class (a -180 KB document already aborts) — depth is the bound that matters. - -This is a conservative scan, not a validator: it tracks `<name …>` / -`</name>` while skipping comments, CDATA, processing instructions and -declarations, and treats `<… />` as non-nesting. A miscount can only cost a -pathological document its fast path, and the failure mode is the existing -one — fall back to the clip-name heuristic. - -## Test rationale - -Detailed rationale for tests in `src/disc/hddvd.rs`'s `#[cfg(test)]` module, -kept here so the in-code doc can stay within the internal-comment cap. - -**`scan_hddvd_feature_composition_saturates_absurd_disc_declared_sizes`** — -The SECOND size accumulator. `compose_xpl_titles` sums an XPL's clip sizes; -this one sums the composed FEATURE title's parts, and it reads the same -disc-declared `u64` Information Length from the same `clip_extents` map. A -split feature whose two parts each declare a size near `u64::MAX` overflows -it exactly as the XPL path did. Nothing reconciles the declared size against -the extents that back it — here each part declares `u64::MAX` while -occupying ten sectors — because the allocation descriptor's length field is -only 32 bits wide, so the disc can always claim more than it holds. -Mutation: restore `size_bytes += *size` in the feature-composition path and -this goes red (debug: attempt to add with overflow). - -**`scan_hddvd_does_not_compose_a_feature_over_an_unrecorded_part`** — A split -feature (FEATURE_1 + FEATURE_2) where ONE part carries an unrecorded extent -must not be composed into a FEATURE title at all. Composing around the -unusable part is the dangerous outcome: the title would play through as if -it were the whole movie while silently missing that part's runtime, and its -size still counts it. The other clips remain available as their own titles -— refusing the composition is not the same as refusing the disc. - -**`scan_hddvd_logs_an_unrecorded_feature_part_with_its_own_code`** — The -refusal above must also be ACCOUNTED, with the unrecorded extent's own code. -This warn site carried a hardcoded `code = 6017` literal — in the very file -whose sibling arm was changed to stop doing exactly that — and nothing -tested it, so neither the literal nor its absence broke anything. Refusing a -feature and saying nothing leaves the operator with a disc that scanned -"fine" and is quietly missing its main title. Mutations: deleting the -`tracing::warn!` (no event to find); restoring the literal `6017` still -passes here BY VALUE, which is the point — what is pinned is -`E_UDF_UNRECORDED_EXTENT`'s value reaching the log, so a renumbering that -the literal could not follow goes red; logging a neighbouring code (e.g. -`E_UDF_NO_USABLE_EXTENT`) goes red immediately. - -**`scan_hddvd_does_not_compose_a_feature_over_a_part_with_no_usable_extent`** -— The `Ok`-but-empty twin of the test above, and the one that actually bites -on an ordinary disc: a feature part whose `file_extents` call SUCCEEDS and -yields nothing usable — here a zero-byte `FEATURE_2.EVO`, no crafted ICB -required — must refuse the composition too, and must say so in the log. -Before this, the empty-`Ok` clip entered neither `clip_extents` nor -`unusable`, so the `any(|n| unusable.contains(..))` guard missed it and the -`filter(|n| clip_extents.contains_key(..))` beside it quietly DELETED the -part: the scan composed a `FEATURE` title out of part one alone, still -advertising itself as the feature, at rc=0, with no diagnostic anywhere. -Half a movie presented as a whole one — strictly worse than the `Err` case -the guard was built for, and reachable without a hostile disc. Mutations -this catches, all of which the pre-fix code exhibited: dropping the -`extents.is_empty()` branch entirely (a `FEATURE` title appears with one -extent); inserting into `clip_extents` anyway (same); marking the clip -unusable but NOT logging (the log assertion fails — absence of a log is -itself the defect); logging the neighbouring `E_UDF_UNRECORDED_EXTENT` -instead of `E_UDF_NO_USABLE_EXTENT` (the code assertion fails, because a -zero-length file is not an authoring hole and triaging the two together -sends whoever reads it at the wrong population). - -**`parse_xpl_titles_caps_a_playlist_declaring_absurdly_many_titles`** — A -disc-supplied playlist nested far deeper than any real one must be REFUSED -BEFORE the XML parser sees it. `roxmltree` is recursive-descent and its -depth-10 limit covers only ENTITY expansion, so unbounded element nesting -blows the thread stack — an ABORT, which no `catch_unwind` and no `Err` -fallback can contain. ~50k levels is a few hundred KB of well-formed XML, -far under the read cap. The correct outcome is the same as any other -unusable playlist: an empty `Vec`, so the caller falls back to the clip-name -heuristic. Separately, this test also exercises BREADTH, not depth: a flat -playlist passes the nesting guard trivially and can still declare a title -per ~51 bytes, so a 64 MiB XPL (exactly `udf::MAX_FILE_BYTES`) reaches ~1.3 -million of them. The cost is drive time, not memory: `compose_xpl_titles` -runs `probe_evo_streams` once per title and each probe reads real sectors, -so an uncapped count turns a scan the operator expects to take seconds into -tens of terabytes of optical reads. `bluray.rs` is not exposed to this -because its playlist count comes from the MPLS files present in the UDF -directory; an XPL DECLARES its titles, so nothing bounds them. Mutation: -delete the `.take(MAX_XPL_TITLES)` and this goes red at the declared count. - -**`nested_title_xpl`** — Builds a playlist that nests `nesting` `<Title>` -elements inside one another and puts `clips` self-closing -`<PrimaryAudioVideoClip>` elements at the innermost level, plus `chapters` -`<Chapter>` elements. `parse_xpl_titles` collects a title's clips with -`descendants()`, not `children()`, so EVERY one of the `nesting` ancestors -collects the SAME innermost clip list — the amplification this exercises. - -**`parse_xpl_titles_caps_clips_per_title_against_descendant_amplification`** -— CLIPS PER TITLE is the axis the title cap, the depth cap and the -directory cap all leave open. `MAX_XPL_TITLES` bounds how many titles a -playlist may declare and `MAX_XPL_DEPTH` bounds how deeply it may nest, but -NEITHER bounds how many `<PrimaryAudioVideoClip>` elements a single title -collects. Worse, the collector is `descendants()`, so one clip nested inside -N ancestor `<Title>`s is collected N times over — the clip count MULTIPLIES -by the nesting the depth guard still permits. A 64 MiB XPL -(`udf::MAX_FILE_BYTES`) holds ~1.7M clip elements at ~38 bytes each; with -the 32 `<Title>` ancestors the depth cap still permits, that is ~56 million -heap-allocated `XplClip`s, and then as many `Clip`s again in -`compose_xpl_titles`. This test uses small numbers that exercise the same -multiplication without a 64 MiB fixture: pre-fix it collected 756 clips per -title, 18,900 across the 25 nested titles, from a document declaring only -756 clip elements — an exact 25x, the nesting count. Mutation: delete the -`clips.len() >= MAX_XPL_CLIPS_PER_TITLE` break and this goes red at the -unamplified per-title count. - -**`parse_xpl_titles_caps_chapters_per_title`** — CHAPTERS PER TITLE is the -same `descendants()` amplification on the second unbounded `collect()` in -the same loop. Bounded separately so closing the clip axis does not simply -move the attack next door. Mutation: delete the -`.take(MAX_XPL_CHAPTERS_PER_TITLE)` and this goes red at the declared count. - -**`parse_xpl_titles_keeps_every_clip_of_a_realistic_title`** — The control: a -realistic multi-clip title still resolves EVERY one of its clips and -chapters. `SYNTH_XPL`'s main movie is a layer-break split — two clips on one -timeline — plus two chapters; losing either to the cap would cost a genuine -disc half its feature. Mutation: set `MAX_XPL_CLIPS_PER_TITLE` to 1 and this -goes red. - -**`parse_xpl_titles_accepts_real_world_nesting_depth`** — The depth guard -must not reject real discs. A genuine `VPLST000.XPL` is a handful of levels -deep; this is the same document the parsing test uses, plus self-closing -tags, a comment and a processing instruction that the pre-parse scanner has -to account for without inflating its depth count. - -**`scan_hddvd_composes_titles_from_xpl_playlist`** — The authoritative path: -when a VPLST000.XPL is present, titles come from the playlist — the -layer-break split (FEATURE_1 + FEATURE_2) is composed into ONE title with -the real duration, name, chapters, and per-clip title-time offsets — not the -clip-name heuristic. - -**`compose_xpl_titles_saturates_absurd_disc_declared_clip_sizes`** — A -title's `size_bytes` is a running sum of DISC-DECLARED clip sizes: the `u64` -UDF File Entry Information Length, which nothing cross-checks against the -extent list that file actually occupies. Two clips can therefore each -declare a size near `u64::MAX`. Summed with a plain `+=`, that overflows: a -panic in a debug build — a crash on untrusted disc content, which this -library must never do — and a wrap to a small garbage total in release, -which misreports the title's size while looking entirely normal. Mutation: -restore `size_bytes += *size` in `compose_xpl_titles` and this goes red -(debug: attempt to add with overflow). - -**`compose_xpl_titles_dedups_repeated_clip_references_by_evo`** — A crafted -Advanced-Content playlist can name the SAME `.evo` in -`<PrimaryAudioVideoClip>` many times over (the XML has no fixed element -count, unlike MPLS's binary PlayItem count). Each repeat must NOT push -another copy of that clip's extent list onto the title — mirroring -bluray.rs's `first_ref = seen_clips.insert(...)` gate, which pushes a clip's -extents only the first time its id is seen. Here the analogous key is the -`.evo` filename (this file has no clip_id; `.evo` is what `clip_extents` is -keyed by). Without the gate, extents grows by the referenced clip's full -extent list on EVERY repetition — unbounded by the number of on-disc files, -bounded only by playlist size. - -**`scan_hddvd_titles_drops_a_split_feature_whose_part_cannot_be_read`** — A -clip whose extents cannot be resolved must drop the SPLIT FEATURE. Catches -reverting the `Err(e) => { warn; unusable.insert(..) }` arm in -`Disc::scan_hddvd_titles` back to the bare `Err(_) => {}` it replaced. RED -BEFORE GREEN, and TWO earlier attempts at this test did NOT go red. The -first asserted "no title names the broken clip" — that passes either way, -because a clip that resolves to no extents is never inserted into -`clip_extents` and so yields no per-clip title regardless. The second fixed -that but shipped no `.vti`: `order` is built solely from -`parse_vti_clip_order` of the navigation file, so with no `.vti` it is -EMPTY, `feature` is empty, and no composed title is ever built — the -assertion held vacuously with the fix reverted. Hence the synthetic -`HVA00001.VTI` fixture: it is what makes the composer run at all. The -defect lives one level up from the per-clip titles, in the composed -feature: `unusable` is what tells the composer that a part is MISSING -rather than merely absent, and the old bare `Err(_) => {}` populated it for -nothing but an unrecorded extent. So a scratched sector under FEATURE_2's -ICB (`Error::DiscRead`) left FEATURE_1 composing a title named "FEATURE" by -itself — half a movie offered as the whole one. - -**`scan_hddvd_titles_excludes_a_clip_with_zero_sectors`** — A clip whose -file has a zero-byte size (a degenerate/empty allocation: its ICB's -allocation descriptor has `data_len == 0`, the UDF AD-list terminator, so -`file_extents` yields no extent at all) must not produce a title. NOTE: this -exercises the *upstream* `data_len == 0` terminator path in -`crate::udf::UdfFs::file_extents`, not the `sectors > 0 && lba > 0` guard in -`scan_hddvd_titles` itself — with this fixture (`file_extents` never returns -a `(lba, 0)` tuple, and `PART_START` unconditionally makes every resolved -`lba` positive) that guard is unreachable in a divergent way; kept here as a -regression check on the zero-byte-file behavior in its own right. - -**`scan_hddvd_titles_probes_once_for_entries_resolving_to_identical_extents`** -— THE THIRD AMPLIFICATION AXIS on HD-DVD title composition, past both -existing guards. `xpl_depth_within_limit` bounds XML NESTING; -`MAX_XPL_TITLES` + the `.evo` de-dup bound what an XPL can DECLARE. Both -live on the playlist path — and an attacker simply omits `/ADV_OBJ`, so -`read_adv_obj_xpl` returns `None` and neither guard is ever consulted. -Control then reaches the clip-name FALLBACK, which emits one title per -`.evo` DIRECTORY ENTRY and probes each. Nothing de-duplicates a FID's ICB -LBA, so every name in a 1 MiB directory (`udf::MAX_DIR_BYTES`, ~24,000 FIDs) -can point at ONE File Entry. Each resolves to the SAME extents and each -costs a full `EVO_PROBE_SECTORS` (16 MiB) probe: ~375 GiB of optical reads -from a directory that describes a single file. Memoizing the probe on the -resolved extent list collapses that to ONE probe, because all of those -names resolve to one extent list. Mutation: drop the memo lookup in -`EvoProbeCache::streams` (always probe) and this goes red at `ENTRIES` -probes. - -**`the_clip_cap_and_probe_budget_bound_a_scans_worst_case_read_volume`** — -The clip cap and the per-probe read budget are ONE bound, not two, and only -their product is meaningful: `MAX_HDDVD_CLIPS` probes each cost up to -`EVO_PROBE_SECTORS`, so the pair fixes the worst-case drive time of a -`Disc::scan()`. `EvoProbeCache` does not help against a crafted disc here: -it keys on the RESOLVED extent list, and distinct-but-overlapping extent -lists all reading one physical region miss the memo every time while every -read succeeds. So the product is genuinely reachable, and it is the number -that has to stay within what a scan can absorb. 8 GiB is the ceiling -asserted here. It is far above any genuine disc — retail HD-DVDs carry tens -of `.evo` clips, i.e. well under 1 GiB of probing — and far below the 64 GiB -the pair used to permit, which exceeded the capacity of the dual-layer media -this format tops out at and took tens of minutes of optical reads. This is -a relationship, not a magic number: raising EITHER constant without -re-examining the other trips it. - -**`scan_hddvd_titles_still_probes_each_distinct_clip`** — CONTROL: -memoization must not silently collapse DISTINCT clips. A legitimate disc -with several different `.evo` files still probes each one, and each title -keeps the streams of ITS OWN clip. Mutation: make `EvoProbeCache`'s key a -constant (ignore the extents) and this goes red — the junk clip inherits -the first clip's H.264 streams. - -**`scan_hddvd_titles_caps_a_directory_declaring_absurdly_many_clips`** — -Memoization alone is NOT the whole fix. Collapsing identical extent lists -bounds the one-File-Entry attack, but distinct extent lists are only as -bounded as the File Entries an attacker cares to lay down — and a File -Entry is ONE sector. A 1 MiB directory's ~24,000 FIDs can each point at -their own 1-sector File Entry (a ~48 MiB image) declaring its own huge -extent, so every probe misses the memo and the amplification is back. -`MAX_HDDVD_CLIPS` is the bound that closes that. Mutation: drop the -`clips.len() < MAX_HDDVD_CLIPS` gate in the directory scan and this goes -red at `ENTRIES`. - -## EvoProbeCache - -`EvoProbeCache` memoizes `probe_evo_streams`, keyed on the RESOLVED extent -list. Probing is the expensive half of title composition: each pass reads up -to `EVO_PROBE_SECTORS` (16 MiB) off the medium. Both composition paths can -reach the same physical clip many times over — an XPL naming one `.evo` from -many `<Title>`s, or a directory whose FIDs all point at one File Entry — and -a probe is a pure function of the extents it reads, so the second and later -passes over an identical extent list are re-reads of bytes already seen. The -key is the extent list itself, NOT the clip name or title: two names for one -File Entry share a key (one probe), while two genuinely different clips have -different `start_lba`s and so keep their own probes and streams. Cache size -is bounded by the caller's title/clip cap (`MAX_XPL_TITLES` / -`MAX_HDDVD_CLIPS`). diff --git a/docs/hevc-mux.md b/docs/hevc-mux.md deleted file mode 100644 index 6ac2c9f0..00000000 --- a/docs/hevc-mux.md +++ /dev/null @@ -1,99 +0,0 @@ -# HEVC Annex B muxer (`src/mux/hevc/mod.rs`) — extended notes - -Overflow detail for comments capped by `ci/comment-guard.py`. Each section is -pointed to from a short `//` comment at the named item. - -## Module overview - -Writes a raw `.hevc` / `.h265` Annex B byte stream: -`00 00 00 01 | NAL_unit | 00 00 00 01 | NAL_unit | …` with no container -framing. On the first frame the muxer emits the codec_private's VPS, SPS, PPS -(parsed from a `HEVCDecoderConfigurationRecord` in `length-prefixed-in-hvcC` -form), then converts each PES frame's length-prefixed NAL units to Annex B -and writes them. - -## `write_frame` - -Input may be either: -- Length-prefixed: `[u32-BE len][NAL bytes]` repeated. This is the form - emitted by libfreemkv's HEVC parser (the MKV-native layout). Converted to - Annex B. -- Already Annex B: a buffer beginning with a `00 00 00 01` or `00 00 01` - start code. Passed through unchanged. - -## `hvcc_to_annex_b` - -Layout (per ISO/IEC 14496-15 §8.3.3.1.2): -- 22-byte fixed header -- byte 22 = `numOfArrays` -- each array: `array_completeness:1 | reserved:1 | NAL_unit_type:6`, - `numNalus:u16-BE`, then `numNalus` × `(nalUnitLength:u16-BE + NAL bytes)`. - -We don't filter on NAL type — VPS (32), SPS (33), PPS (34), and any SEI -arrays included in hvcC all get the same Annex B treatment. - -## `length_prefixed_to_annex_b` - -Already-Annex-B input (a buffer beginning with a `00 00 00 01` or `00 00 01` -start code) is detected up front and passed through unchanged — some -upstream paths (raw HEVC ES from disc) hand Annex B straight through the -PES layer, and a genuine start code would otherwise be misread as a u32-BE -length prefix. - -Truncation policy (single source of truth across all muxers): if a length -prefix runs past the end of the buffer (e.g. a NAL truncated by a bad disc -sector), the truncated trailing NAL is dropped and only the valid Annex-B -prefix accumulated so far is emitted. We never emit a half-NAL nor leak raw -length-prefixed bytes into the Annex-B stream. - -## `nal_length_size` - -Number of octets each NAL length prefix occupies in the elementary data of a -track described by `record` — the `lengthSizeMinusOne + 1` field of the -decoder configuration record (ISO/IEC 14496-15). - -- avcC (`AVCDecoderConfigurationRecord`, §5.3.3.1.2): byte 4 is - `bit(6) reserved | unsigned int(2) lengthSizeMinusOne`. -- hvcC (`HEVCDecoderConfigurationRecord`, §8.3.3.1.2): byte 21 is - `constantFrameRate(2) | numTemporalLayers(3) | temporalIdNested(1) | - lengthSizeMinusOne(2)`. - -The spec permits only 1, 2 or 4 octets (`lengthSizeMinusOne` of 0, 1 or 3); -a declared 3 is non-conformant but is decoded rather than rejected, since -reading N octets is the same operation for every N. A record too short to -carry the field, or a codec with no such record, falls back to -`DEFAULT_NAL_LENGTH_SIZE` — the width every freemkv parser emits. - -This exists because assuming 4 is silent corruption for a legal source: -reading a 2-octet-prefixed frame as u32-BE yields an absurd first length, -the conversion loop bails with nothing parsed, and the raw length-prefixed -bytes are passed through as though they were already Annex B — a stream -with no start codes at all, and no error anywhere. - -## `append_length_prefixed_as_annex_b` - -Same conversion as `length_prefixed_to_annex_b` but writes directly into a -caller-owned buffer, avoiding an intermediate allocation on hot paths (e.g. -per-frame video muxing). If `data` doesn't parse as length-prefixed (no -NALs extracted), it's appended unchanged on the assumption it's already -Annex B. - -## `append_length_prefixed_as_annex_b_sized` - -For a source whose NAL length prefixes are `length_size` octets wide rather -than the 4 this crate's own parsers emit. Derive `length_size` from the -track's configuration record with `nal_length_size` — ISO/IEC 14496-15 lets -a legal avcC/hvcC declare 1 or 2 octet prefixes, and reading those as -u32-BE mangles the frame. - -`length_size` outside `1..=4` is clamped to `DEFAULT_NAL_LENGTH_SIZE`; the -field it comes from is 2 bits wide, so that is unreachable from real input. - -## `avcc_to_annex_b` - -Layout (per ISO/IEC 14496-15 §5.3.3.1.2): -- 5-byte fixed header -- byte 5 = `[reserved:3 | numOfSequenceParameterSets:5]` -- `numOfSPS` × `(sequenceParameterSetLength:u16-BE + SPS bytes)` -- 1 byte = `numOfPictureParameterSets` -- `numOfPPS` × `(pictureParameterSetLength:u16-BE + PPS bytes)` diff --git a/docs/hevc.md b/docs/hevc.md deleted file mode 100644 index 747cffb2..00000000 --- a/docs/hevc.md +++ /dev/null @@ -1,283 +0,0 @@ -# HEVC parser (`src/mux/codec/hevc.rs`) — extended notes - -Overflow detail for comments capped by `ci/comment-guard.py`. Each section is -pointed to from a short `//` comment at the named item. - -## `hevc_num_extra_slice_header_bits` - -`num_extra_slice_header_bits` from a HEVC PPS NAL (H.265 §7.3.2.3): after the -2-byte NAL header, skip `pps_pic_parameter_set_id` + `pps_seq_parameter_set_id` -(both `ue(v)`) and `dependent_slice_segments_enabled_flag` + -`output_flag_present_flag` (`u(1)` each), then read `u(3)`. `None` if the PPS -is too short to parse — the caller then declines to guess a slice type. - -## `hevc_first_slice_coding_type` - -Measures the coding type from the FIRST coded slice of an access unit -(H.265 §7.3.6.1 `slice_segment_header`). Reads only the leading fields of the -first slice segment: `first_slice_segment_in_pic_flag` u(1), the IRAP -`no_output_of_prior_pics_flag` u(1), `slice_pic_parameter_set_id` ue(v), the -`num_extra_slice_header_bits` reserved bits, then `slice_type` ue(v). Returns -`None` for a non-first slice or on truncation — never a guess. `num_extra` -MUST come from the active PPS so the bit offset to `slice_type` is exact. - -## `HevcParser::hdr10` - -Combines the accumulated mastering-display and content-light SEI into a -complete `Hdr10Metadata`, or `None` until BOTH HDR10 SEI messages have been -seen. Requiring both means an SDR / partially-signalled stream never emits a -half-populated (confidently-wrong) HDR10 record. - -## `HevcParser::scan_sei` - -Scans an SEI NAL (`[2-byte NAL header][RBSP]`) for the two HDR10 payload -types and captures each the FIRST time it appears (per-stream constants). - -RBSP structure (Rec. ITU-T H.265 D.2 `sei_rbsp` / `sei_message`): a sequence -of messages, each `payloadType` then `payloadSize` encoded as a run of 0xFF -bytes plus a final <0xFF byte (the "ff-extension" coding), followed by -`payloadSize` payload bytes. Emulation-prevention (00 00 03) is stripped -before reading — unlike a slice header, an SEI payload can be deep enough -that an emulation byte falls inside the fields we read. Unknown payload -types are skipped by their size so a later HDR10 message in the same NAL is -still reached. - -## `HevcParser::mark_clip_boundary` - -MPLS `connection_condition` 0x05 and 0x06 are the non-seamless values (per -the BD-ROM spec: 0x01 = first item / seamless, 0x05/0x06 = non-seamless). -The first CRA at/after this call is rewritten CRA_NUT (21) → BLA_W_LP (16) -so a linear decoder sets NoRaslOutput and discards the now-dangling RASL -leading pictures with no "could not find ref" error. - -It is a no-op for the rewrite unless a CRA actually follows: an -IDR/IDR_W_RADL boundary needs no fix (it carries no cross-splice -references), and the flag is cleared by the first IRAP-class NAL it reaches. - -SAFETY: never call this for connection_condition 0x01 (seamless/first item) -or within a single-clip title — doing so could convert a legitimate -mid-content CRA to BLA. The default (never called) path leaves output -byte-identical. - -## `handle_param_set` - -Handles a VPS/SPS/PPS NAL: decides whether to strip it (the decoder already -has the value) or emit it in-band, and tracks the currently-active body. - -The decision MUST be made against the currently-active set (`cur`), NOT the -codecPrivate copy (`first`). The two player behaviours for hvcC-in-MKV -diverge exactly here: - -- A *seek-capable / Annex-B* player (one that converts hvcC to Annex-B by - inserting the parameter sets) re-applies the hvcC sets at every keyframe. - `reassert_active` handles it. -- A *streaming* decode (a decoder consuming the MKV directly — what most - integrity checkers do) applies hvcC ONCE at init and thereafter updates a - parameter set ONLY from an in-band NAL. - -So when a title redefines a set mid-stream (id 0 body A → B) and later -switches BACK to A (== codecPrivate), the change to A must STILL be emitted -in-band: the streaming decoder is sitting on B and will never revert -otherwise, decoding the whole A-segment against B → CABAC/cu_qp_delta -desync. Stripping on `== first` (the old behaviour) dropped exactly that -revert and corrupted every "switch back to the first body" segment. - -Rules: -- First of its type → seeds codecPrivate; stripped (the decoder gets it from - hvcC at init). -- Equal to the active set `cur` → redundant; stripped. -- Different from `cur` (a change, in EITHER direction) → emitted in-band and - `cur` updated. - -Returns `true` when the NAL was emitted in-band into `frame_data`. - -## `reassert_active` - -Appends the active parameter set `cur` to `prefix` (length-prefixed) so -every keyframe is SELF-CONTAINED: it carries the active VPS/SPS/PPS in-band -ahead of its slices. Skipped only when this access unit ALREADY carried the -NAL in-band (`emitted` — avoids a duplicate) or no active set exists yet. - -Why unconditional (not only when the active set differs from codecPrivate): -a streaming decoder applies the hvcC param sets once at init, then relies on -in-band repetition. Some sources stop repeating a param set at later IRAPs -even though its body is unchanged; if the decoder then drops it (a CRA reset -or SPS event), nothing re-sends it and every subsequent slice fails with -"PPS id out of range" until the next genuine change (observed as a ~24 min -corrupt band on one dual-layer UHD title). Re-asserting the active set at -EVERY keyframe — what compliant Matroska muxers do at every IRAP — makes -streaming decode self-healing. Re-sending an identical param set is benign -(decoders expect it at IRAPs); cost is a few hundred bytes per keyframe. -This strictly supersets the earlier change-only re-assert, so the -param-set-revert fix is unaffected. - -## length-prefix self-check (issue #52) - -`length_prefix_tiles` walks the assembled `frame_data` ([u32-BE len][body] -records) and confirms they EXACTLY tile it: every declared length fits, none is -zero, and the last body ends precisely at the buffer end with no trailing -bytes. `frame_data` is length-prefixed by construction (`push_length_prefixed` -/ `handle_param_set` write a 4-byte BE prefix — `LENGTH_PREFIX_SIZE`, which MUST -equal the hvcC `lengthSizeMinusOne + 1`), so a `false` means a framing desync a -downstream demuxer would report as "Invalid NAL unit size (N>M)". - -`HevcParser::parse` runs this as defense in depth: if the self-check fails it -logs `src` at WARN and DROPS the access unit (`return Vec::new()`) rather than -emit a mis-framed frame. Because no real input can desync a -built-by-construction buffer, the drop branch is exercised end-to-end via a -test-only seam (`FORCE_FRAMING_DESYNC`) that appends a stray byte just before -the guard; see `parse_drops_desynced_access_unit`. Disconnecting the guard from -`parse` fails that test. - -## `parse_mastering_display` - -Parses a Mastering Display Colour Volume SEI payload (Rec. ITU-T H.265 -D.2.28 / semantics D.3.28). Layout — 24 bytes total, all big-endian: -`display_primaries_x[c]` u(16), `display_primaries_y[c]` u(16) for c=0,1,2 -(SEI primary order is c=0 Green, c=1 Blue, c=2 Red); white_point_x u(16), -white_point_y u(16); max_display_mastering_luminance u(32); -min_display_mastering_luminance u(32). Returns `None` if the payload is -shorter than 24 bytes (malformed → ignored, never partially populated). - -## Test notes - -**`hevc_hdr10_sei_keeps_the_first_value_and_ignores_later_repeats`** — the -`scan_sei` match arms are guarded with `self.sei_mastering.is_none()` / -`self.sei_content_light.is_none()`, so the FIRST mastering-display and -content-light SEI a title carries wins and later repeats (every AU of a real -HDR10 stream repeats both) are ignored. Each AU below carries only ONE of -the two messages, so the whole-scan early return -(`if self.sei_mastering.is_some() && self.sei_content_light.is_some()`) -never fires early and the per-arm guards are actually exercised. - -**`sei_nal` (test helper)** — wraps one or more SEI messages in a prefix-SEI -NAL (type 39) preceded by an Annex-B start code. The assembled message bytes -are emulation-prevented (as a conforming encoder would) so they never form a -false start code; the 0x80 RBSP trailing-bits byte is appended. - -**`scan_sei_stops_copying_once_both_hdr10_messages_are_captured`** — MEASURED, -not reasoned: an HDR10 stream carries a prefix SEI per access unit, and -`scan_sei` used to allocate + byte-copy the whole SEI RBSP through -`strip_emulation_prevention` on EVERY one, including after both HDR10 -messages were already captured. On a ~200,000-frame UHD title that is -~200,000 wasted allocations/copies. Counted at the single -`strip_emulation_prevention` call site. - -**`nonzero_num_extra_slice_header_bits_shifts_the_slice_type_offset`** — -`num_extra_slice_header_bits` (H.265 §7.3.2.3) is a PPS field, and the -`slice_reserved_flag[i]` bits it counts sit BETWEEN -`slice_pic_parameter_set_id` and `slice_type` in the slice segment header -(§7.3.6.1). Every earlier fixture used a PPS with the field == 0, so the -skip was never exercised: a parser that ignored the field entirely agreed -with all of them, and would then read `slice_type` from the wrong bit offset -on any real stream that sets it — mislabelling every picture's coding type. - -**`reasserts_active_pps_at_bare_keyframe`** — regression (UHD banded -corruption): a stream redefines PPS id 0 mid-title, then a later keyframe -arrives WITHOUT repeating it (the source relies on the decoder retaining the -redefinition — valid for a raw bitstream). An hvcC player re-applies the -FIRST (codecPrivate) PPS at every keyframe, so the active redefinition must -be re-asserted in-band at that bare keyframe or the whole segment decodes -against the wrong parameter set. - -**`keyframe_param_reassert_does_not_reallocate_the_frame`** — MEASURED: the -keyframe parameter-set re-assert must be spliced into the front of the -already-assembled access unit IN PLACE, not built as a fresh full-size -buffer. It used to `prefix.extend_from_slice(&frame_data)` and then replace -`frame_data` with `prefix`, which grew a few-hundred-byte `prefix` to the -FULL access-unit size — a fresh multi-MB allocation — memcpy'd the whole -frame into it, and dropped the presized buffer. One extra whole-frame -allocation plus one extra whole-frame copy per keyframe: a 2 h UHD title at -24 fps with a 1 s GOP is ~7,200 keyframes, ~14-28 GB of avoidable memcpy per -title. `PARAM_REASSERT_HEADROOM` exists so the splice never reallocates; -this test counts the reallocations that happen, which must be zero. - -**`emits_switch_back_to_codecprivate_pps`** — regression (a UHD title, the -real bug): id 0 is body A (→ hvcC), then redefined to B, then the title -switches BACK to A. A streaming decoder (hvcC at init, in-band updates only) -is sitting on B; the switch back to A must be emitted IN-BAND even though A -== codecPrivate, or the whole A-segment decodes against B (cu_qp_delta -desync). Stripping on `== hvcC` dropped this revert. - -**`cra_at_auto_detected_pts_backstep_rewritten_to_bla`** — regression for a -UHD Dolby Vision profile 7 dual-layer title: a multi-clip title is read as -one concatenated stream and the mpls connection_condition is never plumbed -to the parser, so the splice CRA opening the next clip kept its dangling -RASL leading pictures and a linear decoder flooded "Could not find ref with -POC N". The parser must AUTO-DETECT the boundary from the backward PES-PTS -reset between clips (each .m2ts has its own PTS base) and rewrite that -splice CRA → BLA_W_LP with no explicit `mark_clip_boundary` call. - -**`cra_after_33bit_pts_wrap_not_rewritten`** — regression for the 33-bit PTS -wraparound false-trigger (rc.5.2 audit #1): a SINGLE clip whose raw 90 kHz -PES PTS crosses the 2^33 counter wrap (~26.5 h) must NOT be mistaken for a -non-seamless clip join. Before the fix the raw 2^33→0 backward step armed -`pending_clip_boundary` and the next in-clip CRA was wrongly rewritten -CRA→BLA_W_LP (dropping valid RASL pictures — visible corruption). After -unwrapping onto a monotonic timeline the wrap is absorbed and the CRA stays -CRA. - -**`cra_splice_detected_at_large_pts_magnitude_not_masked_by_wrap_logic`** — -the wrap-vs-backstep test above keeps `high_pts` near the very top of the -33-bit range, close enough to the `PTS_WRAP_PERIOD / 2` threshold that -`high - unwrapped` and a hand-flipped `high + unwrapped` land on the SAME -side of the threshold at every step — it doesn't actually distinguish the -two. This test uses PTS magnitudes around 3e9 (order 2^32, well below the -wrap threshold but reached in well under an hour), where the subtraction and -the addition diverge: ordinary forward progression keeps `high - unwrapped` -small, but `high + unwrapped` would already exceed `PTS_WRAP_PERIOD / 2` on -the very next frame, which would pollute `pts_wrap_offset` by a full period -and mask the genuine two-clip splice that follows. - -**`non_cra_nals_never_rewritten_at_boundary`** — non-CRA NALs are never -rewritten even when a boundary IS marked. IDR (19), RASL (8/9), VPS/SPS/PPS, -and a trailing slice all pass through unmodified; the IDR clears the pending -boundary so no later CRA is wrongly converted. - -**`no_boundary_marker_is_byte_identical`** — a frame stream with NO boundary -marker is BYTE-IDENTICAL to a parser that has no splice-rewrite field at all -(the UHD-safety guarantee): asserts byte-equality of every emitted frame -across a multi-AU stream containing CRAs, IDRs, RASLs, VPS/SPS/PPS, and -trailing slices — none of which is ever marked. - -**`seamless_boundary_no_rewrite`** — a SEAMLESS boundary (connection_condition -0x05/0x06) is expressed by NOT calling `mark_clip_boundary`, so a CRA across -a seamless join is left unchanged. This encodes the contract: only -non-seamless joins call `mark_clip_boundary`; seamless ones never do, so no -rewrite occurs. - -**`redefined_pps_emitted_inline`** — a parameter set REDEFINED mid-stream -(same id, different body) must be emitted INLINE so the decoder -re-activates it. Some discs redefine PPS id 0 partway through the title; -the old parser kept only the first PPS, so the second segment decoded -against the wrong PPS (CABAC desync). - -**`make_sps_full` (test helper)** — builds a stored SPS NAL with sub-layers -and a conformance window, so the parser must skip sub-layer PTL and the 4 -conformance-window ue(v) fields before reaching the bit depths. -`max_sub_layers_minus1` controls the sub-layer loop. - -**`hvcc_array_length_round_trips_above_256_bytes`** — the hvcC array length -is a 16-bit big-endian field written as two separate `push`es: `(len >> 8) -as u8` then `len as u8`. Every VPS/SPS/PPS the rest of the suite feeds is -well under 256 bytes, so the high byte is always 0 and a `>>` -> `<<` -mutation (which also always truncates to 0 for those inputs) is -unobservable there. A real HEVC SPS with an extended VUI/HRD block can -exceed 256 bytes, so this test uses 300+ byte VPS/SPS/PPS and decodes the -16-bit length fields back to confirm they round-trip to the exact NAL -length, not merely a byte that happens to be 0. - -**`hevc_ps_reorder_is_installed_and_flush_drains_its_real_frames`** — HEVC -counterpart of `h264_ps_reorder_reconstructs_distinct_display_pts`. A -DVD/HD-DVD program stream stamps a PTS only on each GOP anchor, so the -parser must reconstruct display-order timestamps for the rest. Two things -are pinned here that nothing else constrains: `with_ps_reorder(true)` must -actually INSTALL the reorderer (a builder that returned a -default-constructed parser would silently leave the transport-stream path -in place on every DVD title), and `flush()` must drain the reorderer's real -buffered frames at EOF — not nothing, and not a manufactured empty frame at -PTS 0 (a zero-length Block whose timestamp jumps back behind every cluster -already written, RFC 9559 §5.1.3.2). Slice bodies follow H.265 §7.3.6.1: -`first_slice_segment_in_pic_flag`, the IRAP-only -`no_output_of_prior_pics_flag`, `ue(pps_id)`, `ue(slice_type)` (Table 7-7: -0 = B, 1 = P, 2 = I). PPS body 0xC0 sets `num_extra_slice_header_bits = 0`. diff --git a/docs/hex.md b/docs/hex.md deleted file mode 100644 index 0081f507..00000000 --- a/docs/hex.md +++ /dev/null @@ -1,26 +0,0 @@ -# hex - -The single hex → bytes parser for the whole workspace. - -## Why one parser - -Key material arrives as hex from three third-party sources — the keydb, an -online key service, and the mapfile's `# freemkv-vid:` comment — and each -used to parse it slightly differently (one stripped `0x`/`0X`, one stripped -nothing, one stripped `0x` only). A key written with a prefix one parser -didn't expect was silently dropped → "can't decrypt" with no error. This is -the one parser they all call, so the prefix/case/validation rules live in -exactly one place. - -## Byte-oriented, not char-oriented - -Operates on BYTES, not `&str` char indices: the inputs are untrusted, so a -multi-byte UTF-8 scalar must reject as malformed, never panic on a -mid-codepoint slice. - -## `byte()` internals - -Combines two ASCII hex-digit bytes into one byte. `as char` is intentional: -for a non-ASCII byte it produces a Latin-1 scalar that `to_digit(16)` then -rejects — so non-hex (incl. `+`/`-` sign chars) and multi-byte input fail -cleanly rather than slipping through `from_str_radix`'s sign handling. diff --git a/docs/identity.md b/docs/identity.md deleted file mode 100644 index 4313d543..00000000 --- a/docs/identity.md +++ /dev/null @@ -1,75 +0,0 @@ -# identity.rs — drive identity test notes - -## `OversizedCountTransport` (test double) - -Returns the requested data length but reports a `bytes_transferred` larger -than the caller's buffer — models a drive that lies about its transfer -count. The old slicing code panicked on this; the clamps in `from_drive` -must keep it from indexing out of range. - -## `inquiry_with_a_short_data_phase_fails_instead_of_reporting_a_blank_drive` - -`ascii_field` with a buffer shorter than `start` returns empty string -rather than panicking. - -- Spec: SPC-4 §6.4.2 — bytes[8:16] are vendor ID; a truncated buffer (e.g. - a device that reports fewer than 8 bytes) must not panic. -- Mutation: removing the `data.len() > start` guard makes it panic on - short inputs. - -A drive that answers INQUIRY with GOOD status but a short or empty data -phase must fail the probe, not present as a blank drive. - -The buffer is pre-zeroed, so decoding it unconditionally yielded empty -vendor/product/revision strings and a byte 0 of 0x00. Every platform -enumerator gates on `raw_inquiry[0] & 0x1F == SCSI_PERIPHERAL_TYPE_OPTICAL`, -and 0x00 is DIRECT ACCESS — so the drive silently vanished from the device -list rather than reporting that its identity probe failed. A USB-SATA -bridge mid-wedge does exactly this. - -The two GET CONFIGURATION calls in the same function already clamped on -`bytes_transferred`, with a comment calling it untrusted; INQUIRY, three -lines above them, discarded it. - -## `ascii_field_boundary_len_equals_start_is_empty` - -`ascii_field`'s guard is `data.len() > start` (strictly greater), not -`>=`: a buffer whose length is exactly `start` has NO byte at that offset, -so it must still yield empty, not attempt to slice. - -Mutation: `>` -> `>=` would try to slice `data[start..]` when -`data.len() == start`, which panics (empty range at the very end is fine, -but the guard's job is the `< start` case below it — pinning the exact -boundary catches an off-by-one either direction). - -## `display_formats_trimmed_fields_space_separated` - -`Display` renders the four trimmed identity fields space-separated — the -human-readable counterpart of `match_key`'s pipe-separated form. Not -exercised anywhere else in this test module. - -Mutation: replacing the `fmt` body with `Ok(Default::default())` writes -nothing at all, so formatting any `DriveId` yields "". - -## `FixedGcCountTransport` (test double) / `from_drive_gc_failure_yields_empty_firmware_date` - -GET CONFIGURATION failure (transport error) must not abort the identity -probe — firmware_date is empty, raw_gc_010c is empty. Mutation: -propagating the GET_CONFIGURATION error with `?` aborts `from_drive`. - -`FixedGcCountTransport` is a transport whose GET CONFIGURATION responses -report an exact, caller-chosen `bytes_transferred` for each of the two GC -features (010Ch firmware date / 0108h serial), so the `end > 12` / `> 12` -boundary guards can be pinned precisely. INQUIRY always succeeds. - -## `from_drive_firmware_date_boundary_exactly_12_is_empty` - -`end > 12` in the firmware-date branch (`from_drive`) is a strict -inequality: `bytes_transferred == 12` reports the field absent (offset 12 -is the first byte of the 12-char date; a count of exactly 12 covers bytes -0..12, none of which is the date), so `firmware_date` must be empty, not -the mutant's off-by-one read. - -Mutation: `>` -> `>=` would try `gc[12..12]` at the boundary — an empty -but non-panicking slice — silently reporting "present" data that is -actually all outside the transferred count. diff --git a/docs/ifo.md b/docs/ifo.md deleted file mode 100644 index a9de6d8d..00000000 --- a/docs/ifo.md +++ /dev/null @@ -1,168 +0,0 @@ -# IFO parser notes - -Overflow rationale relocated from `src/ifo.rs` comments (see `// See docs/ifo.md` -pointers in that file for the anchor point of each section below). - -## `DvdTitle::feature_start_cell` (Bug-4: scene-selection/logo at head of feature) - -The main feature's PGC can open with leading cells that are NOT part of the -movie — a scene-index segment or an interleaved-angle sub-block. This walks -the leading run and returns the index of the first cell that is a plain -feature cell (category `0x00`-class). - -Conservative by construction — it can NEVER truncate a normal feature: -- It only ever skips a *prefix*; the scan stops at the first plain-feature - cell and keeps everything from there on. -- A normal single-angle feature has category `0x00` on cell 0, so the scan - stops immediately at index 0 and drops nothing. -- It never drops on duration or any heuristic — only on the spec category - bits — and it never drops the FIRST cell of an angle block (the angle we - keep). -- As a final guard it never returns past the last cell, and never drops when - that would leave zero cells. - -For a real disc where every feature cell category is `0x00` and chapter 1 -sits at 00:00:00, this returns 0 — a no-op — which is the correct result: the -disc's scene-index lives in a separate menu/title PGC, not in leading cells -of the feature PGC, so there is nothing to drop here. - -## `bcd_to_frames` / `bcd_to_secs` (dvd_time_t is a timecode, not wall-clock time) - -Format: `[hours_bcd, minutes_bcd, seconds_bcd, rate_and_frames]` - - Byte 0: hours in BCD (e.g. 0x01 = 1 hour, 0x12 = 12 hours) - - Byte 1: minutes in BCD - - Byte 2: seconds in BCD - - Byte 3: bits 7-6 = frame rate flag (01=25fps, 11=29.97fps), - bits 5-0 = frame count in BCD - -The seconds field advances once every [`DvdRate::nominal_fps`] frames. On -NTSC discs that is every 30 frames, but 30 frames of 30000/1001 fps video -last 1001/1000 s — so reading H:M:S as literal seconds under-reports real -time by exactly 0.1% (3.6 s per hour). Callers that need real seconds must go -through the frame count, which is what `bcd_to_secs` does. - -## `MAX_TT_SRPT_TITLES` / `parse_tt_srpt` - -DVD-Video caps a disc at 99 titles (VMGI TT_SRPT `TT_Ns`, and the 99-title / -99-title-set structure the format is built around), but the on-disc count is -an untrusted `u16`: a ~800 KB crafted IFO can declare 65535 entries, each -re-parsing a PGC into a full `DvdTitle` (~540 MB of `DvdInfo`). Headroom: this -IS the format maximum, so it clips no conformant disc — a real DVD cannot -address a 100th title through TT_SRPT. - -`parse_tt_srpt` has two bounds on untrusted input: the declared entry count -is clamped to `MAX_TT_SRPT_TITLES`, and entries naming a `(vts_number, -vts_title_num)` pair already seen are dropped. De-duplication is a -correctness fix as well as a bound: two TT_SRPT entries pointing at the same -VTS title are the same title, and `parse_pgcit` would otherwise re-parse -that one PGC into a separate `DvdTitle` per entry. - -## `parse_audio_attr` visibility - -`pub(crate)` for the CROSS-MODULE tests only. The sole production caller is -`parse_vts_attributes` in this file; `src/mux/mkv.rs`'s `#[cfg(test)]` block -calls it directly so its language-mapping tests run the real parser over real -on-disc IFO bytes end to end, instead of a hand-built `DvdAudioAttr` that -could agree with the muxer while both disagree with the disc. Narrowing this -would mean either a `#[cfg(test)]`/`#[cfg(not(test))]` pair of signatures -that can drift apart, or moving those tests away from the code they exist to -pin — both worse than the widened crate-internal visibility, which reaches no -public API. - -## `assign_audio_sub_stream_ids` - -On DVD-Video the sub-id's low nibble is the audio-stream *number* (0-7), -shared across all codecs — the single stream index the PGC `audio_control` -table / navigation registers select — and the high nibble is the codec base. -So the sub-id is `codec_base | position`, where `position` is the stream's -index in the IFO audio-attribute table (NOT a per-codec running count): - - AC-3 → `0x80 | i` - - DTS → `0x88 | i` - - LPCM → `0xA0 | i` - - MP1/MP2 and anything else → `None` (regular MPEG-audio PES, not a - private-stream-1 sub-id). - -A per-codec ordinal was wrong: it only coincides with the wire id when a -codec's first stream is also the disc's audio stream #0. Any codec that is -not the first audio stream (e.g. a DTS track after an AC-3 track) then got -a sub-id one-too-low, so the demux routing key (`0xBD00 | sub_id`) never -matched and the track muxed silent. The positional index is the real wire -number, so distinct positions still give distinct sub-ids (no collision). - -Position saturates at 7 so a malformed over-count never produces an -out-of-range sub-id. - -## `parse_raw_dvd_lang_bytes` - -The salvage is deliberately not narrowed to a-z: whatever survives is only -ever a lookup key for `dvd_lang_to_iso639_2`, which degrades anything it does -not recognize to `und`, so a stray `X` or `5` costs nothing and cannot reach -an output stream as a language code. - -## `dvd_lang_to_iso639_2` - -Uses `labels::vocab::iso639_1_to_iso639_2`, which spans the WHOLE of ISO -639-1 (plus the withdrawn spellings `iw`/`in`/`ji` that DVD-Video's -frozen-1988 language list still puts on disc). The narrower `vocab::menu_lang` -table is deliberately NOT used here: it exists for Blu-ray menu-graphic -filename tokens and knows only 25 languages, so a Region-2 disc's Romanian, -Bulgarian, Croatian, Serbian, Slovak, Slovenian, Hebrew, Estonian, Latvian, -Lithuanian and Icelandic tracks would all fold onto `und` together. DVD -streams carry an empty `label`, so the language is the only thing -distinguishing one subtitle track from the next — a valid code that is -identical for six tracks is worse for the user than the invalid one it -replaced. Both tables normalize to ISO 639-2/T, so they agree wherever they -overlap. - -An empty or unrecognized code degrades to `"und"` (ISO 639-2 / Matroska's own -"undetermined" value) — a valid element value — rather than passing through -an invalid 2-letter code or an empty string. Never guesses. - -## `video_attr_absolute_bytes_pin_real_layout` test - -ABSOLUTE-BYTE pin (audit §3 #2): the existing video-attr tests build the byte -via `v_atr_byte(...)`, which uses the SAME shift constants the parser reads -with — a co-edit of constant + helper would silently re-introduce the -PAL-as-NTSC bug and every test would still pass. This test feeds -`parse_video_attr` HARDCODED bytes captured from real DVD-Video layouts -(DVD-Video video attributes: mpeg_version[7-6] video_format[5-4] -display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's bit -positions drift, these fail. - -## `dts_after_ac3_uses_positional_substream_id` test - -Regression (The Punisher 2004): audio[0]=AC-3 5.1, audio[1]=DTS 5.0. The DTS -track sits at audio position 1, so its wire sub-id is 0x89 (0x88 | 1), NOT -the per-codec 0x88. With the old per-codec ordinal it got 0x88 → demux -routing key 0xBD88 had no match → every DTS packet (which carries 0x89) was -dropped → the track muxed present-but-silent while the AC-3 (at position 0, -where ordinal and position coincide) played fine. Positional numbering fixes -it end-to-end. - -## `pgc_chapter_times_ntsc_no_pulldown_drift` test - -Regression test for issue freemkv#25 (NTSC chapter drift). Builds a PGC from -the real cell table of the disc in the bug report and checks the chapter -marks against the reference tool's values. Before the fix, chapter 14 landed -4.019 s early; the drift was proportional to elapsed time (0.1%), so a short -synthetic fixture would not have caught it. - -## Test additions note - -The block of tests starting at `cell_category_low_flags_are_bit_isolated` -covers cell-category bit isolation, palette/program-map arithmetic, and the -malformed-language salvage paths. - -## `build_pgc` test fixture - -Builds a standalone PGC starting at offset 0. Layout: 0xEA-byte header, then -the program map (one byte per program, the 1-based first cell number), then -the 24-byte cell playback table. `cells` are `(category, BCD time, -first_sector, last_sector)`. - -## `build_pgcit` test fixture - -Builds a VTS_PGCIT at offset 0: VTS_PGC_Ns(2) + reserved(2) + -VTS_PGCIT_EA(4), then one 8-byte VTS_PGCI_SRP per PGC (VTS_PGC_CAT(4) + -VTS_PGCI_SA(4), the PGC's byte offset from the VTS_PGCIT start). `pgcs` are -appended after the SRP table. diff --git a/docs/image-writer.md b/docs/image-writer.md deleted file mode 100644 index 2c7bfe8c..00000000 --- a/docs/image-writer.md +++ /dev/null @@ -1,35 +0,0 @@ -# `src/io/image_writer.rs` - -## Why this is not `freemkv_engine::copy` - -The engine's `copy` is the RECOVERY path — mapfile sidecar, `--multipass` -sweep/patch, damage-jump, ECC-aware batching, auto-resume. Every one of those -exists because an optical drive returns read errors on marginal media. A -file-backed or synthesized source has no marginal media: a read either -succeeds or the underlying file is broken, and retrying it is pointless. - -Routing a non-drive source through the recovery path is not merely wasteful, -it is wrong. Its mapfile identity check compares AACS unit keys and the VID, -both of which are empty for an already-decrypted source, so identity passes -for ANY such source: a second run with a different input to the same output -path would resume over the previous image and produce wrong content at exit -zero. Keeping the two paths separate makes that unrepresentable. - -So: drive sources get `freemkv_engine::copy`. Everything else gets this. - -## `write_image` rationale - -The output is a faithful image of whatever the source presents — decrypted -if the caller wrapped the source in a -[`DecryptingSectorSource`](../src/sector/decrypting.rs), ciphertext if it did -not. `write_image` performs no decryption itself and makes no decryption -decision; that belongs to the caller, which knows whether the run is -`--raw`. - -On cancellation the partial file is left in place — the caller decides -whether a partial image is worth keeping, and deleting a multi-gigabyte file -the user may want to inspect is not this function's call to make. - -A short read from the source is treated as an error rather than zero-filled: -silently padding a truncated source would produce an image that looks -complete and is not. diff --git a/docs/index-bdmv.md b/docs/index-bdmv.md deleted file mode 100644 index dee83470..00000000 --- a/docs/index-bdmv.md +++ /dev/null @@ -1,10 +0,0 @@ -# `/BDMV/index.bdmv` layout - -Binary layout of the BD navigation index (First Play, Top Menu, and the -title table), as parsed by `src/bdnav/index.rs`. - -`"INDX"` + version(4) + `indexes_start`(u32 @8) + … ; at `indexes_start`: -`index_len`(u32), First-Play object(12), Top-Menu object(12), -`num_titles`(u16), then `num_titles` title objects(12 each). Every object's -`object_type` is the top two bits of its first byte; for an HDMV object the -`id_ref` into `MovieObject.bdmv` is a big-endian u16 at object offset 6. diff --git a/docs/index-select.md b/docs/index-select.md deleted file mode 100644 index aa8fd860..00000000 --- a/docs/index-select.md +++ /dev/null @@ -1,7 +0,0 @@ -# FMTS index selection - -Where the resolved index comes from is a separate concern from disposition -classification (`resolve_disc_index` in `src/aacs/index_select.rs`): today it -is read off the index keys the key source handed us; when Processing Keys are -available it will come from the VK derivation instead. Either way the -disposition logic in `unit_disposition` is identical. diff --git a/docs/inf.md b/docs/inf.md deleted file mode 100644 index e07f03cb..00000000 --- a/docs/inf.md +++ /dev/null @@ -1,100 +0,0 @@ -# `aacs::inf` — notes - -## `UnitKeyFile`'s hand-written `Debug` - -Redacting `Debug`, per the policy `aacs::types` documents: this struct holds -the disc's ENCRYPTED CPS unit keys — exactly the material a keydb entry stores -— plus the disc hash they are looked up by. A derived `Debug` printed every key -byte verbatim, so any `{:?}` (a downstream crate, an `assert_eq!` failure -message, a future `tracing::debug!` in this module) leaked them. Only -non-secret shape is printed. Guarded by `unit_key_file_debug_is_redacted`. - -The disc hash is the public keydb lookup key, printed as hex the same way -`DiscEntry` prints its own — never as raw bytes. - -## `parse_vtkf` - -Layout — AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, a fixed -2480-byte file, verified byte-exact against real discs (Freedom `VTKF090`, -Dukes of Hazzard `VTKF000`): -```text - [0x00..0x0C] magic "DVD_HD_V_TKF" - [0x0C..0x10] BE32 HD_VTKF_SIZE (2480) - [0x10..0x1C] associated playlist name ("VPLST%%%.XPL") - [0x1C..0x80] reserved - [0x80..] 64 entries × 36 bytes: - BIFO (1) | reserved (3) | ENCRYPTED title key (16) | binding MAC (16) - BIFO bit 7 (AV_FLG) set = this slot holds a title key - (pre-recorded discs fill the binding MAC with 0xFF) - [0x9A0..2480] 16-byte TKF MAC (CMAC keyed by Kvu — NOT a key) -``` -The slot index (1-based) is the CPS unit number, so an absent slot is -SKIPPED (not a terminator) — collapsing gaps would renumber later keys and -hand the wrong title key to CPS unit N+1. The title→CPS mapping is -playlist-driven (`VPLST%%%.XPL`) and owned by the HD DVD enumerator, so -`title_cps_unit` is left empty here. - -The prior parser used a 32-byte stride (a 12-byte pad instead of the 16-byte -binding MAC). That reads entry #1 correctly but drifts +4 bytes per entry -after it, so it only decrypted single-CPS-unit discs; every multi-key VTKF -(real multi-CPS-unit HD-DVD titles) yielded garbage keys for CPS unit ≥2. - -## `synth_vtkf` test helper - -Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS -HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes -`VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64 -entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG` -set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC. - -## `unit_key_file_debug_is_redacted` test - -`UnitKeyFile` holds the disc's ENCRYPTED CPS unit keys. A derived `Debug` -printed every byte; the hand-written impl must not. Sentinel key byte -0xD5 = decimal 213 (a derived `Debug` renders `[u8; 16]` in decimal), the -same probe `aacs::types::redaction_tests` uses. Mutation guard: putting -`#[derive(Debug)]` back fails this. - -## `read_mkb_from_drive_returns_the_concatenated_pack_payload` test - -`read_mkb_from_drive` is the in-drive MKB source: every AACS derivation -downstream (`mkb_find_mk_dv`, the subset-difference walk, the whole -Media Key ladder) consumes exactly what it returns. An empty return is -not a benign "no MKB" — it is a total read failure reported as success, -and every derivation then fails with a key-not-found code that points -the operator at their keydb rather than at the drive. - -This pins the CONTENT: the concatenated payload of all packs, in pack -order, byte for byte. - -## `read_mkb_from_drive_issues_the_exact_mmc_cdb_for_each_pack` test - -The CDB is what the drive actually acts on, and every byte of it is -load-bearing: a wrong format code returns a different disc structure -entirely, and a wrong allocation length truncates the pack. The existing -test above pins the opcode, the format code and the pack number; this -pins the WHOLE 12-byte CDB, so no field can drift unnoticed. - -Expected layout (MMC-6 READ DISC STRUCTURE, AACS MKB format): -`[0]` opcode, `[1]` media type 0x01, `[2..6]` address = pack number -(BE32), `[6]` layer 0, `[7]` format 0x83, `[8..10]` allocation length -BE16 = 32772 = `0x80 0x04`, `[10..12]` reserved/control. - -## `read_mkb_from_drive_accepts_a_full_size_pack` test - -A pack payload filling the FULL 32768-byte window must come back whole. -The `len > 0 && len <= 32768` bound is what stands between a maximal -pack and a silently dropped one, and the small payloads used elsewhere -in this module never reach it. - -## `read_mkb_from_drive_ignores_a_pack_declaring_more_than_the_buffer_holds` test - -A drive that DECLARES more payload than it returned must not be -believed. The BE16 length in the response header is drive-supplied data: -a firmware bug, a short transfer, or a hostile device can put a value in -it that runs past the 32772-byte buffer. Copying `len` bytes on that word -alone panics the rip thread mid-scan. - -Both the first-pack read and the per-pack loop carry the same bound, so -both are exercised here: the over-declared pack contributes nothing and -the honest pack still comes through. diff --git a/docs/io-mod.md b/docs/io-mod.md deleted file mode 100644 index 06be855f..00000000 --- a/docs/io-mod.md +++ /dev/null @@ -1,34 +0,0 @@ -# `src/io/mod.rs` — background - -## `WritebackFile` - -Drop-in wrapper around `std::fs::File` for any call site that performs -large sequential writes (sweep, patch, mux, etc.). It implements `Write` -and `Seek` so existing code paths can swap `File` for `WritebackFile` -with no body changes. Internally it drives a `WritebackPipeline` that, -on Linux, drains dirty pages continuously at 32 MB granularity to avoid -the kernel's accumulate-then-burst flush behaviour. macOS and Windows -use a no-op pipeline — their default cache policies have not been shown -to exhibit the same pathology for this access pattern. - -## `FileSectorSource` - -The read-side dual of `WritebackFile` — it implements -[`crate::sector::SectorSource`] for an ISO file using direct -`pread`-equivalent calls so the kernel's own readahead policy runs -(which interleaves naturally with the concurrent writeback). It pairs -that with periodic `posix_fadvise(DONTNEED)` drops on the consumed -window so an 85 GB streaming ISO read doesn't fill the page cache and -starve the concurrent MKV write. - -## `Pipeline` + `Sink` - -The generic producer/consumer primitive used by sweep, patch, and mux -to overlap reads with writes via a bounded channel + dedicated consumer -thread. - -## `byte_prefetcher` - -The read-ahead producer feeding the mux pipeline for `io::Read`-backed -sources: a worker thread fills a recycled pool of buffers and ships -them through a channel, exposing `BytePrefetcher` / `PrefetchShell`. diff --git a/docs/jar.md b/docs/jar.md deleted file mode 100644 index 5555ba90..00000000 --- a/docs/jar.md +++ /dev/null @@ -1,25 +0,0 @@ -# src/labels/jar.rs — relocated comment prose - -## `MAX_CLASS_BYTES` rationale - -A real BD-J `.class` is far under this ceiling (64 MiB); a lying header -simply gets truncated and the class fails to parse, which is skipped like -any other bad entry. The buffer is grown incrementally and the read is -capped here rather than pre-sized from the declared size. - -## `for_each_jar` "top-level" vendor examples - -"Top-level" means entries directly under `/BDMV/JAR/`, not nested under a -subdir. Pixelogic, Criterion, Paramount, etc. put their data files inside -`/BDMV/JAR/<x>/`; dbp and Deluxe put their jar directly at -`/BDMV/JAR/<name>.jar`. - -## `read_is_bounded_by_cap` test rationale - -The read is bounded by `MAX_CLASS_BYTES`: a stored entry whose real payload -exceeds the cap yields only the first `MAX_CLASS_BYTES` bytes to the -parser, never the full (unbounded) entry. Verified here on a small cap via -the entry-count path: the truncated bytes still parse a valid class -prefix, but no read beyond the cap occurs. The test asserts the entry is -still surfaced exactly once (the cap does not drop legitimate entries) and -that the call returns. diff --git a/docs/key-provider.md b/docs/key-provider.md deleted file mode 100644 index 3cc59ed9..00000000 --- a/docs/key-provider.md +++ /dev/null @@ -1,68 +0,0 @@ -# KeyProvider (src/aacs/provider.rs) - -Long-form notes for `src/aacs/provider.rs`, relocated from doc comments to -stay under the comment-guard caps. See the module's own (trimmed) `//!` doc -for the short version. - -## Method flavors - -`KeyProvider` methods come in two flavors: - -- **Bulk material** (`device_keys`, `processing_keys`, `media_keys`) — the - resolver unions (and dedups) results across all providers and tries each - candidate. -- **Disc-keyed lookup** (`lookup_disc_by_hash`, `lookup_disc_by_vid`) — the - resolver short-circuits on the first hit, so providers are queried in - array order with fastest/closest first. - -`host_certs` is a sixth method but is NOT consumed by the resolver chain: -the SCSI handshake reads host certs directly from the caller-supplied -credentials, not from the provider array. A provider that overrides -`host_certs` today has no effect on the handshake; the method is retained -as a forward-looking extension point only. Correspondingly, -`Providers::host_certs` (the union helper) is `#[allow(dead_code)]` and -unused by the resolver chain today. - -Default impls return empty / `None` so backends only override the methods -they actually support — an external key service might implement only -`lookup_disc_by_hash`, while a local file might implement all six. - -Calls may block (disk I/O, network round-trips). The resolver invokes each -method at most a handful of times per scan; for per-disc memoization, -implementations should cache internally. - -## `Providers` (resolver-side aggregation helper) - -The resolver wraps `ctx.providers` (`&[&dyn KeyProvider]`) in `Providers`; -its methods apply the union-vs-short-circuit policy described above per -method. The bulk unions dedup so overlapping providers don't make the -resolver re-walk/re-validate identical material. - -## `SuppliedKey` (bridge for `Disc::decrypt_with`) - -A `KeyProvider` backed by a single caller-supplied key's raw material — the -bridge for `crate::disc::Disc::decrypt_with`. - -The application's key source did the lookup and handed in material at one -level (DK / PK / MK / VUK). This exposes exactly that material to the -version-dispatched resolver, which owns ALL derivation — so a source never -derives, and the lib remains the single home for the AACS chain across -1.0 / 2.0 / 2.1 / 2.x. - -Each level fills only its own field; the rest stay empty, so the resolver -naturally runs the matching path (DK→…, PK→…, MK-pool brute, or a -disc-keyed VUK hit). `decrypt_with` already knows the disc, so the -`lookup_disc_by_*` hash/VID arguments are irrelevant — a present -`disc_entry` is returned for any query. - -## Test: `providers_host_certs_unions_every_providers_certs_in_array_order` - -`Providers::host_certs` is the union across the provider array. It is not -wired into the handshake today (see above), so nothing else in the crate -would notice a body that dropped every cert on the floor — and the day it -IS wired in, a silently-empty cert list means the drive AACS authentication -finds no host certificate to present and every disc fails to open, with no -indication that the caller's certs were discarded. - -Unlike the bulk key unions this one does NOT dedup (`HostCert` is not -Ord/Hash), so the assertion is on the full concatenation in array order. diff --git a/docs/keysource.md b/docs/keysource.md deleted file mode 100644 index a020e8ea..00000000 --- a/docs/keysource.md +++ /dev/null @@ -1,280 +0,0 @@ -# keysource.rs — extended notes - -Overflow rationale relocated from `src/keysource.rs` doc comments by the -comment-guard (prose caps: 8 lines for pub-item rustdoc, 3 lines for internal -comments). Each section below is pointed to by a short `//`/`///` comment at -the corresponding site in the source. - -## Module overview — ownership split - -libfreemkv still OWNS the crypto: the boil-down primitives and the AES live -here. A source owns only PATH ORCHESTRATION — deciding which primitive to -call with what input for the material it happens to hold. Source -implementations are published in the companion `freemkv-keysources` crate, -keeping key *policy* (which store, which order, online vs local) out of the -library. It knows what material it holds (a DK / MK / VUK / pre-decrypted UK) -and what it must fetch from the disc (VID, MKB, encrypted title keys, content -samples) to get there. - -## `MIN_SAMPLE_UNITS` — why this many, why here - -The key service identifies a key by which of the submitted units it decrypts, -so too few samples — especially on FMTS, where a segment interleaves several -variants at the unit level — can return a key that matches an incidental unit -rather than the one asked about (a false positive). This many distinct units -make the request unambiguous. - -Canonical here (the base crate) so BOTH consumers agree on one value: the -online source in `freemkv-keysources` (which refuses to send an under-sampled -request) re-exports it, and libfreemkv's own FMTS forensic query -([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the -reverse import (keysources depends on libfreemkv, not vice versa), so the -value lives at the lower layer both share. - -## `DecodeSampleSet` — parse, don't validate - -"Parse, don't validate": the only constructor, `DecodeSampleSet::new`, returns -`None` for an under-sized slice, so an online key request simply *cannot be -built* from too few samples. The runtime `len() < MIN_SAMPLE_UNITS` check that -used to live at the request site (and was silently forgotten by an -under-sampling caller, reading as "key service down") becomes a compile-time -obligation: a request builder that takes `&DecodeSampleSet` can never receive -an unchecked `Vec`. - -The *count* enforced here is a runtime property of the disc (how many -encrypted units it yields); the *requested* count is a caller-side -compile-time constant that callers pin to `MIN_SAMPLE_UNITS` (see e.g. -autorip's `SAMPLE_UNITS`). Together the two make under-sampling -unrepresentable at the request boundary. - -The wrapped samples are on-disc AACS ciphertext — the same bytes the sibling -`DiscInputs::samples` redacts as key MATERIAL — so `Debug` is hand-written and -redacting. - -## `DecodeSampleSet`'s hand-written `Debug` - -A derived `Debug` dumped every wrapped sample verbatim: a `DecodeSampleSet` -carries at least `MIN_SAMPLE_UNITS` 6144-byte aligned units (>= 49 KiB, in -practice multi-MB) of AACS ciphertext plus each unit's clear 16-byte -derivation seed, so one `tracing::debug!("{set:?}")` on a failed `/decode` -request — or an `assert_eq!` whose panic message formats it — wrote all of it -to the log that gets attached to a bug report. Same policy and same shape as -`DiscInputs`'s impl. - -## `DiscInputs`'s hand-written `Debug` - -Redacting `Debug`, per the policy `aacs::types` documents (and which -`aacs::types::Vid` already applies to this very Volume ID). `DiscInputs` is -public and returned by `crate::Disc::inputs`, so a consumer's -`tracing::debug!("{inputs:?}")` used to print the Volume ID, the whole -`Unit_Key_RO.inf` (the encrypted title keys), the entire MKB and every -ciphertext sample verbatim into a log that ends up attached to a bug report. -Only non-secret identity and shape (presence, lengths) is printed. - -## `DiscInputsCtx::new` — parse-failure policy - -A present-but-malformed `unit_key_ro` (truncated / wrong magic / wrong -stride) parses to an empty key set, so a later `enc_title_keys` returns -`Ok(&[])` indistinguishably from a disc that legitimately has no title -keys — the parse failure is swallowed here, not surfaced as an error. - -## `KeySource` trait — the two resolve operations - -Returning an empty `Vec` means "no key for this disc from this source"; an -`Err` means the source itself failed (I/O, parse, network). The caller -(`resolve_and_apply`) tries each source in order and validates the returned -keys against real ciphertext before committing them, so a wrong key from one -source transparently falls through to the next. - -Two explicit resolve operations, one per key kind — never one overloaded call -whose meaning depends on how many keys came back: -* `get_unit_keys` — the disc's base per-CPS-unit Unit Keys (index space = - CPS-unit number). The common path for every disc. -* `get_fmts_indexes` — the AACS 2.1 forensic index keys (index space = - forensic index 1..N). Defaults to empty: a source with no forensic material - opts out, and only an FMTS disc ever asks. - -What each source must do to answer is the source's own business: a keydb keys -on `disc_hash` and reads no samples; the online source submits the ctx's -content samples (a base batch for `get_unit_keys`, an index-1 anchor batch -for `get_fmts_indexes`) to the key service. - -## `resolve_unit_keys` — de-conflating "no entry" from "matched, no key" - -`get_unit_keys` returns a bare `Vec<UnitKey>`: an empty result cannot say -WHETHER the source had no entry for this disc at all, or matched the disc and -still derived nothing (the classic "matched but needs a VID this path can't -supply"). `resolve_unit_keys` carries that distinction. It returns a -`UnitKeyResolution`: - -* `keys: Vec<UnitKey>` — the terminal Unit Keys produced (empty = none). -* `matched: bool` — the source matched this disc in its store (by hash / VID) - even if it then derived no key; `false` is a genuine miss. -* `miss_path: Vec<KeyNode>` — when `matched` and `keys` is empty, the nodes - walked AFTER the implicit `MatchedDisc` node (e.g. `[NoVid]` or - `[NoDerivableKey]`); empty means the caller supplies a bare - `NoDerivableKey`. Ignored unless `matched` and `keys` is empty. -* `matched_entry: Option<MatchedEntry>` — a booleans-and-lengths shape of the - matched entry (no key material) for the application to log. -* `store_entries: Option<usize>` — per-disc entries loaded in this source's - store, so a true-miss verdict can name the store size. - -The trait's default is coarse: forward to `get_unit_keys` and report -`matched = false`. A source that keys on a per-disc identity (a keydb) -overrides it to set `matched` / `miss_path` / `matched_entry`, so the trace can -render `matched disc > no VID > NO KEY` instead of a flat `no entry`. See -docs/aacs-trace.md for the trace-node shapes. - -## `resolve_and_apply_traced` — one-shot semantics and CPS-unit numbering - -One-shot per source: each source's `KeySource::get_unit_keys` is called -exactly once with a `DiscInputsCtx` over `inputs`. Non-empty Unit Keys are -mapped to terminal `Key::Unit`s and applied via `crate::Disc::decrypt_with`, -which validates them against `inputs.samples` and only mutates the disc on -success — so a wrong/partial key set is rejected and the loop continues. - -CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index -from `crate::aacs::derive::decrypt_unit_key`; the library's canonical -CPS-unit number is `position + 1` (matching -`crate::aacs::inf::parse_unit_key_ro`'s `(i + 1)`), so the committed -`AacsState.unit_keys` is byte-identical to the library-resolved path. - -The NUMBER itself is not what descramble indexes by — but the ORDER is -load-bearing, so a source must return its keys in CPS-unit order. Trial -decrypt-and-check was deliberately deleted (see `crate::decrypt::AacsKeyMap`: -decryption is driven by the disc's CPS-unit / FMTS-segment structure, "never -by trial-decrypt-and-check per unit"), and `decrypt_sectors_mapped` indexes -the committed pool POSITIONALLY — `unit_keys[key_idx].1`, where `key_idx` is -a POSITION in the Vec a source returned, recorded by -`resolve_mux_key_map_cached` / `resolve_fmts_key_map`. Return the same keys -in a different order and every `AacsKeyMap` points at the wrong key: the -whole title decrypts under a neighbour's key, or a forensic range trips the -`is_clean` net into `DecryptFailed`. (The doc used to say the number "is -cosmetic for descramble (the decrypt path strips it and tries every key)", -which is what the DELETED trial-decrypt path did; the only place that still -tries every key is `Disc::decrypt_with`'s sample VALIDATION, which does not -descramble content.) - -## `fetch_unit_keys` — the two callers - -This is exactly what both paths do — only the samples differ: -* at disc open, `ctx` carries reachable-content samples → resolves the - up-front CPS units (the common one), -* in the read, on a decrypt miss, `ctx` carries the FAILING unit's ciphertext - → resolves the CPS unit that wasn't sampled up front. - -Same sources, same call; there is no separate "fetch". Unlike -`resolve_and_apply` this does not validate/commit to a disc — the read's -decorator re-decrypts with the returned keys, which is the validation. - -## `FetchOutcome` — the error-signal struct - -Whether a driver run resolved keys, and — when it did NOT — whether the miss -was a genuine "no source holds this key" (`errored == false`) or at least one -source FAILED (`errored == true`, e.g. a network source was unreachable). The -distinction gates negative-result memoization: an empty-because-absent -result is safe to cache, an empty-because-a-source-was-down result is -transient and must NOT be cached (the key may resolve once the source -recovers). - -## `drive_unit_keys` - -`fetch_unit_keys` plus the error signal: drive `sources` in order, return the -first source's non-empty Unit Keys, and flag whether any source that failed -to answer did so with an `Err` (a source failure) rather than an empty `Ok` -(genuine absence — see `KeySource::get_unit_keys`). - -## `key_fetch` — one builder, memoization policy - -One builder, used by every read path (sweep / patch / mux) and every -consumer (CLI, autorip) — neither application contains the fetch logic, only -its key-source config. Cheap to clone; build once, clone into each read -path. `make_sources` is invoked per fetch (the cold path) so the resolver -stays `Send + Sync` without requiring `KeySource: Send`. - -Each operation is memoized per sample-batch fingerprint (keys are disc-level, -so repeats hit cache). A genuinely-empty reply is cached, but a source -FAILURE is not — a transient miss (see `FetchOutcome::errored`). - -## `read_encrypted_units` — why here, and the CPI selection rule - -Lives in the library, not a key-source crate: reading the disc and carving -AACS units is decryption *mechanism* (unit geometry anchored at each -extent's `start_lba`), which the library owns. A key source is *handed* -these bytes via `DiscInputs.samples`; it never reads the disc itself. - -"Encrypted" is decided by `crate::aacs::content::aacs_unit_encrypted` — the -AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the -spec-correct signal (`buf[0] & 0xc0`). NOT the `is_clean` TS-sync heuristic: -a unit lacking clean TS syncs does not imply encryption (an FMTS variant -frame or an odd clear unit can lack syncs yet be unencrypted), and a clear -unit sent to a key server yields nothing to validate against — the "0 -encrypted units" rejection. A clip opens with clear navigation units -(PAT/PMT, menus) whose CPI is clear; only CPI-flagged content units are -collected — probing several points spread across EACH extent so a title -whose encrypted body starts late (or whose midpoint lands in clear nav) -still yields samples. CPI is read at each extent's `start_lba` -(clip-file-anchored), so byte 0 is a real unit start and the flag is -meaningful. - -## Test doc overflow - -### `key_source_host_certs_defaults_to_empty` -Spec: a source holding no cert returns the empty vec; the `mkb` param is -forward-looking and the default ignores it. Mutation: a default returning a -non-empty vec would inject phantom certs into the OEM handshake. - -### `errored_empty_is_not_cached_and_retries_when_source_recovers` -A transient source outage must NOT be memoized as a permanent "no key": a -fingerprint whose first fetch failed because the source errored must be -re-asked, and once the source recovers the key resolves. Regression guard -for the negative-result memoization fix — caching the errored empty would -permanently drop a recoverable unit for the rest of the op. - -### `key_fetch_routes_unit_and_fmts_to_distinct_source_methods` -The two `KeyFetch` operations route to the two DISTINCT trait methods: -`unit_keys` drives `get_unit_keys`, `fmts_indexes` drives -`get_fmts_indexes`. A source that returns different keys per method proves -the seam no longer collapses "1 base key" and "the forensic set" into one -overloaded call — the operation, not the return length, decides which. - -### `read_encrypted_units_finds_scrambled_content_off_the_midpoint` -#4 regression: encrypted content NOT at the extent midpoint (a -late-starting feature, or a midpoint landing in clear nav) must still be -sampled — empty samples make `decrypt_with` skip wrong-key validation. The -old midpoint-and-forward sampler returned empty; the probe-spread finds the -early scrambled band. - -### `read_encrypted_units_selects_by_cpi_not_ts_sync` -DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the TS-sync -clarity heuristic. Half the units lack TS syncs but are CPI-CLEAR -(`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely lack TS -syncs; the old sampler collected these and the key server rejected the POST -as "0 encrypted units". `read_encrypted_units` must skip them and return -ONLY CPI-flagged units. A regression to selecting by TS-sync clarity would -collect the CPI-clear units too and fail the `& 0xC0` assertion. - -### `disc_inputs_ctx_parses_unit_keys_at_the_version_stride` -Audit #5 — a DISCRIMINATING test for the version→stride fix. A 2-key -`Unit_Key_RO.inf` whose SECOND key sits at the V20 (64-byte) offset; a V10 -(48-byte) parse reads a DIFFERENT region. Confirms `DiscInputsCtx` parses at -the stride for `inputs.version` — a swapped `from_major` branch or a -hardcoded stride (the exact bug 1.2.0 fixes) would fail this, where the -prior single-key fixtures passed regardless of stride. - -### `disc_inputs_debug_is_redacted` -`DiscInputs` is public and returned by `Disc::inputs`, so any consumer's -`tracing::debug!("{inputs:?}")` prints it. A derived `Debug` printed the -Volume ID (the value `aacs::types::Vid` deliberately renders as -`Vid(<redacted>)`), the whole `Unit_Key_RO.inf` (the encrypted title keys), -the entire MKB and every ciphertext sample verbatim. Sentinel byte 0xD5 = -decimal 213, matching `aacs::types::redaction_tests`. Mutation guard: -restoring `#[derive(Debug)]` fails this. - -### `decode_sample_set_debug_is_redacted` -`DecodeSampleSet` is public and wraps the SAME on-disc ciphertext the -sibling `DiscInputs` redacts, so a derived `Debug` dumped >= MIN_SAMPLE_UNITS -x 6144 bytes of verbatim AACS ciphertext (plus every unit's clear 16-byte -derivation seed) into any log that formatted it. Sentinel byte 0xD5 = -decimal 213, matching `aacs::types::redaction_tests` and the `DiscInputs` -test above. Mutation guard: restoring `#[derive(Debug)]` fails this. diff --git a/docs/labels-mod.md b/docs/labels-mod.md deleted file mode 100644 index b2bc69a9..00000000 --- a/docs/labels-mod.md +++ /dev/null @@ -1,556 +0,0 @@ -# src/labels/mod.rs — design notes - -Long-form rationale relocated from doc comments per the comment-guard -audience cap (internal items cap at 3 lines; see `ci/comment-guard.py`). - -## `label_at` - -The VENDOR label occupying 1-based STN slot `n` of `stream_type`, if any. - -Slot lookup is restricted to labels with no `StreamId`, and that -restriction is the whole point. `stream_number` is not one coordinate -system, it is two that share a name: - - * on a vendor label it is a slot in the one stream table the config blob - describes, which is what this function's callers count against; while - * on a derived label it is that stream's slot in *its own* playlist's - table, which says nothing about any other playlist's numbering. - -Reading a derived label out of a slot lookup therefore answers a question -about table A with a fact about table B. A label that carries a `StreamId` -names the stream it describes outright and is bound by that id instead — -see `apply_labels`. - -## `find_anchor` - -Find the title the label list is actually describing, for one stream type. - -A vendor label list is a single stream table — one playlist's STN slots — -but it is handed to every title on the disc. When sibling playlists cover -the same clip with different stream subsets, per-title ordinal numbering -puts the same label on different physical PIDs in each, and the flags -contradict each other. The list itself carries no playlist id, but it does -carry a language per slot, and that sequence is a fingerprint: on the -corpus, exactly one title's per-type language sequence reproduces the label -list position for position, and content confirms that title's binding is -the correct one. - -So: the anchor is the title that `anchor_score` admits and that confirms -the most of the list. `None` means no title matches — the list describes a -stream table this disc scan cannot see, and nothing may be bound -authoritatively. - -Only VENDOR slots take part. Derived labels (MPLS / CLPI) name their own -stream and bind directly, so admitting them here would be asking a title to -agree with a numbering that describes a different playlist — the mistake -that made this gate a coin flip while the merged list was the only list -there was. - -## `anchor_score` - -How strongly this title's stream sequence matches the vendor label list, or -`None` if the title contradicts it and cannot be the table it describes. - -A stream disqualifies the title when the vendor label on its slot states a -different language. A slot the vendor list does not name constrains nothing: -under-yield is the normal shape of these blobs — an authoring layer ships -editorial labels for the streams it considers interesting and leaves the -rest as bare slots — so a hole is the list's silence, not a disagreement. - -The score is how much of the list the title positively CONFIRMS: slots where -both sides state a language and state the same one. A list that names no -languages at all scores zero everywhere and the ranking falls through to -table size, which is what it did before this became a fingerprint. - -## `apply_labels` - -Apply a pre-extracted set of labels to titles' streams. - -Every label either NAMES the stream it describes or it does not, and that -split — not a slot number — decides how it binds. Four tiers, most certain -first: - - 1. **This title is the anchor.** `find_anchor` identifies the title - whose stream table the vendor list is describing, so its slots are the - list's own slots and bind directly. - - 2. **An anchor proved this PID.** Every slot of the anchor yields a - `(clip, PID) -> label` fact, and a PID is the same physical stream in - every playlist that plays that clip. Sibling playlists bind through - that map, so a vendor label lands on the same elementary stream no - matter which playlist enumerates it, and a slot the anchor never showed - us is not bound at all. - - 3. **The label names this stream.** A derived label carries a - `StreamId` read out of the very table the stream itself was built - from, so `(clip, PID)` equality is identity, not inference. This is the - floor that gives every stream its language and codec. - - 4. **Ordinal, language-checked.** A vendor label with no anchor behind it - falls back to the 1-based per-type STN slot — but only where tier 3 had - nothing to say, and only when the languages do not contradict. - -Tier 3 outranking tier 4 is the substance of this ordering: a label that -states which stream it belongs to beats a guess about which stream it might -belong to, even when the guess is the richer label. The policy where a title -cannot be bound confidently is to leave the stream alone: an unlabelled track -is a much smaller harm than a full-dialogue track wearing `forced`, which -presents to the user as a duplicate of the track they wanted, and which the -muxer can only undo on discs that state `forced_on_flag`. A subtitle label -carries nothing but the qualifier, so an unverifiable one has no upside at -all to trade against that risk, and the ordinal path applies one only when -the label and the stream both state a language and it is the same one. - -Audio streams update `purpose` + `label` (codec/variant info; never -English purpose text). Subtitle streams update `qualifier` and the -`forced` flag. - -Extracted from `apply()` so the matching logic is unit-testable without -needing a SectorSource / UdfFs. - -## `merge_mpls_floor` - -Merge the MPLS-derived floor into a framework parser's label list. - -The two lists do not share a coordinate system, and the merge must not -pretend they do. A framework `stream_number` is a slot in the one stream -table the vendor blob describes; an MPLS label's is that stream's slot in -its own playlist's table. Matching them by number — which is what this -function used to do — merges by an equality that means nothing, and the -merged entries then land on whatever stream happens to sit at that ordinal -in each title. - -So nothing is merged BY slot. Every MPLS label whose stream is not already -named in `framework` is appended, keeping its own `StreamId`, and -`apply_labels` decides per stream which of the two reaches it: the -vendor's editorial label where an anchor puts it there, the floor -everywhere else. Framework labels stay richer and stay ahead — they are -simply no longer competing for a slot number. - -Sorted at the end (vendor slots first, in slot order, then the named -streams) so callers see a deterministic list. - -## `append_clpi_orphans` - -Append CLPI ProgramInfo streams that no existing label already names. -These are "orphan" streams — physically present in the .m2ts per CLPI's -clip-authoritative view, but no MPLS playlist references them, so the -framework and the MPLS floor both missed them. Empirically they are -commentary or alternate-version streams the authoring tool left out of the -published playlist. Returns the number appended. - -Each carries the `(clip, PID)` it was read under, so it binds to that -stream and to nothing else. It used to be given `stream_number = -max(existing per type) + 1, +2, …`, an ordinal invented here and shared -with the slot numbering the vendor list uses — which meant a title with -more streams than the list had slots could reach an orphan by counting, and -be labelled from a stream no playlist even plays. There is no ordinal now: -a stream that is in no playlist is in no title, so an orphan label binds to -nothing, which is exactly right and is now structural rather than lucky. - -## `select_result` - -Pick the winning parser result from `results` (built in PARSERS order): -highest `Confidence` among non-empty results, with the earliest array -position winning on a tie — matching `extract()`'s strict-`>` first-wins -scan. - -`Iterator::max_by_key` returns the LAST maximal element, so the key is -`(confidence, Reverse(index))`: among equal-confidence entries the one -with the smallest index has the largest `Reverse(index)` and is selected, -i.e. first wins. - -## `collect_chapter_summary` - -Scan `/BDMV/PLAYLIST/*.mpls`, parse each, return a row per playlist with -chapter count (entry marks only) 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. - -## `jar_inventory` - -List filenames found under any `/BDMV/JAR/<x>/` subdirectory of the disc. -Deduped, sorted. Returns an empty vec if no JAR dir is present. -`pub(crate)` so filename-based parsers (e.g. `png_filenames`) can scan -menu-asset names without a reader. - -## `jar_inventory_from` - -The body of `jar_inventory`, over the `/BDMV/JAR` children directly, so -it is unit-testable without a `UdfFs`. - -A `BTreeSet`, not `Vec::contains`: the entry names come from the disc's own -UDF directory records, so both the file count and the name lengths are -attacker-controlled, and a linear `contains` doing a full `String` compare -per candidate is quadratic in the number of files. The set also subsumes -the trailing sort — it yields sorted, deduplicated output directly. - -## test: `jar_inventory_dedup_does_not_hang_on_a_hostile_directory` - -`jar_inventory` deduplicated with a linear `Vec::contains`, doing a full -`String` comparison per candidate — quadratic in a file count taken -straight from the disc's UDF directory records, with attacker-chosen -name lengths to inflate each comparison. - -This is a HANG GUARD, and the name says so: a return to the linear scan -makes this fixture run for minutes (120 000² / 2 comparisons over a -180-byte shared prefix), which without the deadline would wedge CI -rather than fail it. It is not a complexity proof — no assertion here -can distinguish `BTreeSet` from any other sub-quadratic dedup, and the -clock-free half of the claim (dedup, sort, directory exclusion) belongs -to `jar_inventory_dedups_sorts_and_skips_dirs` below. - -The deadline is a real margin, unlike the 6x one that made -`paramount.rs`'s wall-clock test flake under a loaded CI box: measured -at 0.14 s debug / 0.07 s release against 10 s, so ~70x. A shared CPU -does not close that; a quadratic dedup does not survive it. - -## test: `parsers_registry_order_locked` - -Lock the parser roster + order. If someone reorders the array or -adds/removes a parser, this test forces them to update the expectation -explicitly. The order is load-bearing: first matching `parse()` wins, so -reordering changes which parser claims a disc on overlapping detect -signals. - -dbp + deluxe MUST stay at the end (their detect triggers on "any BD-J -disc"; placing them earlier would short-circuit the stricter parsers -above them). - -## test: `select_result_first_wins_on_tie` - -`select_result` must pick the highest-confidence non-empty result and, -on a confidence tie, the FIRST in array order (regression for the old -`analyze()` `max_by(...then(Equal))` no-op that picked the LAST). - -`extract()` — the path that actually ships — used to re-derive this -same rule with its own inline `>` scan and had no test at all, so the -bug this test guards against could have recurred there unnoticed. It -now calls `select_result`, so this test covers both. - -## test: `suppression_is_by_named_stream_not_by_slot_number` - -Spec: the merge suppresses a floor entry only when the list already -NAMES that stream. A framework label occupying the same slot number is -not the same fact — a vendor slot and a playlist STN slot are different -coordinate systems — so it suppresses nothing. - -This is the merge rule inverted from what it used to be: the old key was -`(stream_type, stream_number)`, which dropped a floor entry whenever -some unrelated vendor slot happened to share its number, and kept one -whenever it did not. Both outcomes were decided by an accident of -counting. - -## test: log-injection guard on playlist-name logging - -A playlist name is a raw UDF directory entry — disc-controlled bytes, -validated no further than a lossy UTF-8 decode. Logging it through -tracing's `%` (Display) sigil writes those bytes VERBATIM, so a crafted -`.mpls` filename carrying ANSI escapes or control characters forges -terminal output and log structure in any consumer rendering the event -(CWE-117). `?` (Debug) escapes them, and `str`'s Debug is exactly the -escaping this needs. - -`info!` is not covered by the debug/trace-logging exemption: this fires -on an ordinary rip of an ordinary disc. - -Mutation: put `%` back on `playlist` in `apply_labels` and this goes red. - -## test: `forced_label_follows_the_pid_not_the_ordinal_across_sibling_playlists` - -The cross-playlist mis-binding this module's two-tier binding exists to -stop, in its measured shape: two playlists cover the identical feature -clip, one of them enumerates one extra subtitle ahead of the forced -slots, and the vendor label list describes the shorter of the two. -Numbering the same list from 1 inside each title puts the `Forced` -label on a different PID in each — in the longer playlist, on the full -dialogue track. The user then sees two identical-looking English -subtitle tracks, one of them wrongly flagged forced. - -Binding by the PID an anchored title proved, rather than by ordinal, -puts `forced` on the same two physical streams in both playlists and -leaves the extra one alone. - -## test: `ordinal_binding_drops_a_subtitle_label_that_contradicts_the_stream_language` - -No anchor to work from (nothing on the disc reproduces the label list's -language sequence), so binding falls back to the STN ordinal — and a -label that names a different language than the stream it would land on -is evidence the list is describing some other stream table. Drop it: -unlabelled beats mislabelled, because the payload here is `forced`. - -## test: `generate_video_label_secondary_dolby_vision_el` - -Spec: a secondary (dependent-view) video stream with Dolby Vision -enhancement layer gets the brand string "Dolby Vision EL"; every other -HDR format on a secondary stream gets no label at all (that wording is a -CLI concern). Mutation: delete the `HdrFormat::DolbyVision` arm so it -falls through to the `_ => String::new()` catch-all, losing the brand. - -## test: `generate_video_label_480_boundary` - -Spec: 480 lines is the SD floor — a stream with height exactly 480 must -get the "480p"/"480i" token (BD spec height boundary), not fall through -to the empty-resolution case. Mutation: `h >= 480` -> `h < 480` inverts -the boundary so a legitimate 480-line stream (h == 480) produces no -resolution token at all. - -## test: `generate_video_label_sdr_produces_no_hdr_token` - -Spec: SDR is the unmarked default — it must never appear as a token in -the generated label (only non-SDR formats get an explicit tag). -Mutation: delete the `HdrFormat::Sdr` arm so it falls through to -`_ => parts.push(hdr.name())`, appending a spurious "SDR" token. - -## test: `generate_audio_label_atmos_folds_brand` - -Spec: the Atmos-aware variant folds "Atmos" into the codec brand name -for TrueHD/DD+ carriers, distinct from the plain wrapper. Mutation: stub -the whole function to `String::new()` / a constant literal — either way -it stops reflecting the codec/channel inputs. - -## test: `generate_audio_label_covers_pc_container_codecs` - -Spec: every disc-audio codec in the enum has a full marketing name, -including the lossy PC-container codecs (AAC/MP2/MP3/FLAC/Opus) that -`generate_audio_label_all_codecs` above doesn't cover. Mutation: delete -any one of these match arms — the codec falls through to -`_ => return String::new()`, silently losing its label. - -## `codec_hint_consistent`: chained-OR boundary hardening - -The family-detection booleans chain `h.contains(..) || ...` synonym -checks. Each test below isolates ONE clause so a `||` -> `&&` flip there -changes the verdict. - -### `codec_hint_consistent_truehd_space_synonym` - -Isolates the `"true hd"` (space form) synonym in `says_truehd`, which -mutant testing hit at 396:44's `||`. If that `||` is weakened to `&&`, -"True HD" alone (no "truehd" substring) no longer sets `says_truehd`, -`names_family` goes false entirely (no other family clause matches), and -the function takes the "no family named" early-return path — turning a -should-be-`false` verdict for a mismatched codec into `true`. - -### `codec_hint_consistent_ddp_ac3_plus_no_hyphen_synonym` - -Isolates the `"ac3+"` (no-hyphen) synonym in `says_ddp` (398:9's `||`). -A hint matching only this clause must still classify as DD+, not fall -through to the plain-AC3 `says_ac3` check. - -### `codec_hint_consistent_ddp_eac3_synonym` - -Isolates the `"eac3"` synonym in `says_ddp` (401:9's `||`), the last -clause before the chain moves to "digital plus"/"dd+". - -### `codec_hint_consistent_lpcm_bare_pcm_synonym` - -Isolates the `"pcm"` (no "lpcm") synonym in `says_lpcm` (409:40's `||`). -A bare "PCM" hint on a non-LPCM stream must still be judged -inconsistent — if the `||` were `&&`, "PCM" alone would fail to set -`says_lpcm`, `names_family` would go false, and the function would take -the "no family named" path, wrongly returning `true` for ANY codec. - -### `codec_hint_consistent_names_family_dts_ma_alone` - -Isolates the `says_dts_ma || says_dts_hr` disjunction inside the -`names_family` chain (418:60). A hint that sets `says_dts_ma` alone -(e.g. "Master Audio", without "hd ma") must still make `names_family` -true; weakening that `||` to `&&` requires both clauses at once, so -`names_family` goes false and the function wrongly reports "consistent" -for a codec the hint never named. - -### `codec_hint_consistent_truehd_arm_atmos_alone` - -Isolates the `Codec::TrueHd => says_truehd || says_atmos` arm (433:38). -An Atmos-tagged hint that names a DIFFERENT lossless carrier by name -(DD+) must still be judged consistent with a TrueHd stream purely on the -Atmos marker — `||` -> `&&` would require the hint to ALSO say "truehd", -which an Atmos-only marker doesn't. - -### `codec_hint_consistent_dts_arm_not_bypassed` - -Spec: `Codec::Dts` is consistent ONLY when the hint's DTS-family -bookkeeping (`says_dts`) is true, not just because `names_family` is -true via some other carrier. Mutation: delete the `Codec::Dts => -says_dts` arm (438:9) — it falls to `_ => true`, so ANY named family is -(wrongly) "consistent" with a Dts stream. - -### `codec_hint_consistent_lpcm_arm_not_bypassed` - -Spec: `Codec::Lpcm` is consistent ONLY when `says_lpcm` is true. -Mutation: delete the `Codec::Lpcm => says_lpcm` arm (439:9) — same -bypass-to-`_ => true` failure mode as the Dts arm above. - -## test: `slot_lookup_never_returns_a_label_that_names_its_own_stream` - -Spec: a label that names its own stream is never reachable through the -slot lookup, whatever number it carries. - -The two numbers are different coordinate systems. A derived label's -`stream_number` is its slot in ITS OWN playlist's table; the vendor's is -a slot in the one table the config blob describes. `label_at` answers -questions about the second, so it must not return the first. - -Mutation: drop the `l.stream_id.is_none()` term from `label_at` — the -derived label at slot 1 is found first and shadows the vendor's. - -## test: `the_derived_floor_cannot_veto_the_anchor` - -Spec: the derived floor does not vote on which title anchors the vendor -list — and in particular cannot VETO the title that does. - -The vendor list names two subtitle slots, eng then fra, and the feature -reproduces them. The feature has a third subtitle the vendor said -nothing about; the merge dropped a derived label into "slot 3", numbered -in a different playlist's coordinate system, and it says Spanish. Read -as part of the vendor sequence that is a contradiction, so the feature -is rejected, no title anchors, and every flag the list carries is lost. - -This is the measured cost of a whole-sequence gate without provenance: -the vendor's own slots stay correctly positioned while the merged ones -break the sequence between them. - -Mutation: drop the `l.stream_id.is_none()` term from `label_at` — the -derived "spa" is read as vendor slot 3, `anchor_score` returns `None`, -and there is no anchor. - -## test: `the_anchor_is_the_title_that_confirms_most_of_the_list` - -Spec: among titles the vendor list admits, the one that CONFIRMS more of -it wins — a slot where both sides state a language and state the same -one is evidence; a slot where either is silent is not. - -Here the shorter title matches both named slots outright while the -longer one states no language at all, so it merely fails to contradict. -Size alone would hand the anchor to the title that proved nothing. - -Mutation: rank on `n` alone (drop `score` from the comparison) — the -three-stream title with no languages wins. - -## test: `a_vendor_qualifier_does_not_leak_onto_a_featurettes_own_stream` - -Spec: an editorial qualifier the vendor list states for ONE stream table -does not leak onto a different physical stream in a title that merely -counts to the same ordinal. - -Measured shape: the feature carries eleven subtitles whose first is SDH; -a dozen featurettes each carry a single English subtitle, on a different -clip and a different PID. Both are "subtitle number 1", so the ordinal -fallback put SDH on all of them — and the languages agree, so the -language gate could not catch it. What separates them is that the -featurette's stream is named by the derived floor, and a label that -names a stream outranks a label guessed onto it. - -Mutation: move the `by_id` lookup in `resolve` after the ordinal -fallback — the guess wins again and the featurette is SDH. - -## test: `an_anchor_proves_pids_only_for_the_clip_its_table_came_from` - -Spec: the anchor proves a `(clip, PID)` fact only for the clip its -stream table was READ FROM — the first play item — never for every clip -the anchor happens to play. - -`disc::bluray` builds a title's stream list from `play_items[0]`'s STN -table, and tier 3 fifty lines below says exactly that by keying its -derived ids on `clip0`. Tier 2's harvest contradicted it: it recorded -the anchor's slot PIDs against EVERY clip the anchor plays, so a sibling -playlist that plays a LATER clip of the anchor bound the anchor's -editorial label onto whatever stream in that clip happens to reuse the -PID — a different physical stream, in a different clip, whose own -language says so. - -Mutation: harvest over `&title.clips` instead of its first clip — the -featurette wears the feature's SDH again. - -## test: `a_vendor_codec_claim_does_not_follow_the_ordinal_onto_a_bonus_clip` - -Spec: a vendor codec/variant claim does not follow the ordinal onto a -bonus clip that carries a different codec. - -Measured shape: the feature's first audio is object audio; a dozen menu -and bonus titles each carry one plain stereo track. All of them are -"audio number 1", and the hint names the same codec family as the stream -it lands on, so the consistency guard passes it through and every bonus -clip advertises the feature's format. - -Mutation: as above — move `by_id` after the ordinal fallback. - -## test: `a_title_shorter_than_the_vendor_list_cannot_anchor_it` - -Spec: a title whose stream count is smaller than the vendor list's -highest slot cannot be the table that list describes. - -Mutation: drop the `n < extent` term from `find_anchor` — the one-stream -title becomes eligible, and on a disc where it sorts first it takes the -anchor away from the title that actually has the slots. - -## test: `an_unnamed_slot_does_not_disqualify_a_title` - -Spec: a slot the vendor list never names constrains nothing. - -These blobs under-yield by design — the authoring layer ships editorial -labels for the streams it finds interesting and leaves the rest as bare -slots. Treating a hole as a disagreement rejects the very title the list -describes. (Before provenance the holes were filled with labels numbered -in an unrelated coordinate system, so the gate neither rejected nor -admitted on evidence.) - -Mutation: make `anchor_score` return `None` for an unnamed slot — no -title anchors and the forced flag is never delivered. - -## test: `fill_gaps_leaves_order_untouched_when_nothing_added` - -Spec: the sort-by-(type, number) pass only runs when the merge actually -added something (`added > 0`); when MPLS contributed nothing new, -`framework`'s existing order (however the caller built it) must be left -untouched. Mutation: `added > 0` -> `added >= 0` is always true, so the -sort runs unconditionally, silently reordering a framework list that -wasn't already in (type, number) order even on a no-op merge. - -## `build_program_info` / `build_clpi` (test fixtures) - -`build_program_info`: builds a CLPI ProgramInfo section for one program -with the given (pid, stream_coding_info) pairs. Layout mirrors -`crate::clpi::parse_program_info`'s expectations: length(4) + reserved(1) -+ num_programs(1), then per-program spn(4)+pmt_pid(2)+num_streams(1) -+num_groups(1), then per-stream pid(2)+sci_len(1)+sci. - -`build_clpi`: builds a full CLPI byte buffer (HDMV header + ProgramInfo) -declaring the given (pid, coding_type, lang) streams. `sci` layout -follows `crate::clpi::parse_program_info`'s per-coding-type match arms: -PG/IG = coding_type + 3-byte lang; audio (primary or secondary) = -coding_type + format/rate byte + 3-byte lang. - -## test: `orphans_state_no_stn_slot_and_name_their_stream` - -(d) An orphan states NO STN slot, and is identified by the stream it was -read from instead. - -This test used to assert the opposite — that orphans continue the slot -numbering from `max(existing) + 1`. That number was invented here and -shared with the coordinate system `label_at` counts vendor slots in, so -a title with more streams of a type than the vendor list had slots could -reach an orphan by counting and be labelled from a stream no playlist -plays. An orphan is by definition in no playlist, hence in no title, so -there is no ordinal to give it. - -## `codec_hint_consistent` - -Does the parser's `codec_hint` name a codec consistent with the stream's -actual `codec`? `apply_labels` uses this to keep richer-but-consistent -hints (e.g. "Dolby Atmos" on a TrueHD stream — Atmos is a TrueHD extension -the raw spec codec can't express) while rejecting mis-bound ones (e.g. -"AC-3 2.0" on a TrueHD stream, the shuffled-label bug). Matching is by codec -FAMILY parsed out of the hint string. "Atmos" with no carrier named is -treated as compatible with its lossless carriers (TrueHD / E-AC-3). A hint -naming no recognizable codec family (pure editorial, e.g. "Commentary") is -consistent — it isn't asserting a codec. - -## `generate_audio_label_atmos` - -Atmos-aware variant: same codec/channel string as `generate_audio_label` -with the object-audio marker folded into the codec brand (e.g. "Dolby -TrueHD Atmos 7.1"). The "Atmos" string lives here in the label layer, not -in the core parser. Used when a bitstream probe detected an Atmos -substream and the stream still carries the basic (non-editorial) label. diff --git a/docs/labels-xml.md b/docs/labels-xml.md deleted file mode 100644 index 812e077d..00000000 --- a/docs/labels-xml.md +++ /dev/null @@ -1,26 +0,0 @@ -# src/labels/xml.rs - -Tolerant XML scraping helpers — promoted from two near-duplicate hand-rolls -in `paramount.rs` (attribute extraction) and `criterion.rs` (tag-text -extraction). - -These are NOT a full XML parser. They handle the subset of XML the BD-J -authoring tools we've seen actually emit: ASCII tag/attr names, no entity -references inside label strings, optional XML namespaces. Hardening goals -over the prior `find("<tag>")` / `find(r#"name=""#)` matchers: - -1. **Case-insensitive** tag and attribute names — vendors casing is - inconsistent across authoring-tool revisions. -2. **Namespace-aware** — strip an optional `ns:` prefix so `<ns:playlist>` - and `<playlist>` both match. -3. **Whitespace-tolerant** — multiple/tab/newline characters around `=` - between attribute name and value; whitespace inside the opening tag. -4. **Quote-style tolerant** — both `"value"` and `'value'`. -5. **Self-closing tag handling** — `<tag />` and `<tag/>` both work; - `text()` returns `Some("")` for empty content. - -Out of scope (intentionally simple): XML entity decoding (`&`, `<`, -etc.), CDATA sections, comments, processing instructions, DTD -declarations. None of the BD-J authored disc data we've observed -exercises any of those — labels are plain ASCII/Latin-1 in attribute -values. diff --git a/docs/lfsr.md b/docs/lfsr.md deleted file mode 100644 index f137eff6..00000000 --- a/docs/lfsr.md +++ /dev/null @@ -1,166 +0,0 @@ -# CSS content cipher (`src/css/lfsr.rs`) - -## Module overview - -The algorithm is the publicly documented CSS stream cipher, described in the -open CSS cryptanalysis literature. It is implemented here from that public -description; its constants (see [`super::tables`]) are the cipher's own -defined values. Nothing in this file is copied or translated from any -particular CSS software. - -The cipher uses two table-driven linear-feedback circuits: -- **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from - `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`. -- **LFSR0** — a 24-bit feedback register (seeded from `key[2..5] XOR - seed[2..5]`), stepped through a feedback polynomial and `TAB4`. - -Each output byte is the sum-with-carry of the two register outputs. A body -byte is recovered as `plain = TAB1[cipher] ^ keystream` — a `TAB1` -substitution of the ciphertext byte followed by an XOR with the keystream -(so the cipher is deliberately not its own inverse). - -## `descramble_sector` - -The two feedback registers are seeded **directly** from -`title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key -mangling on the content path (that belongs to the disc/title-key hierarchy, -not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed: -`body[i] = TAB1[body[i]] ^ (keystream & 0xff)`. - -The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This -routine CLEARS that flag after unscrambling, so a descrambled sector reads as -`sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from -ciphertext, and re-running descramble on an already-cleared sector is a no-op -(the flag guard below skips it). Clearing does not affect the recovered body. - -`sector.len() < 2048` (the encrypted region `0x80..0x800` not fully present): -callers chunk by 2048, so a trailing partial chunk is left untouched. The -`debug_assert!` flags this misuse in debug/test builds; a DVD sector is -always exactly 2048 bytes. - -## `scramble_sector` (test helper) - -Exact inverse of `descramble_sector`: turn a plaintext sector body into -CSS ciphertext under `title_key`. - -Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the -inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical -keystream. The keystream derivation is the same as `descramble_sector`; -only the final substitution differs. Bytes `0x80..0x800` are rewritten in -place; the scramble flag is set to `0x10` so a subsequent descramble runs. - -Not on any production read path — it exists so the key-recovery tests (and -any caller that needs a known CSS-encrypted sector) can build genuine -ciphertext rather than approximating it. - -## Test notes - -### `descramble_produces_the_reference_css_vector` - -Regression vector: the deterministic output of the CSS content cipher for -a fixed key/seed/body. The value is generated by this implementation and -is self-consistent with the scramble/descramble round-trip test — any -correct CSS descrambler yields the same bytes, since the cipher is -deterministic. Pins the implementation against accidental change. - -key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA. - -### `css_descramble_inverts_scramble_over_body` - -The content cipher is NOT a plain XOR involution (it applies TAB1 to the -ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is -`scramble_sector`. Scrambling a plaintext body and then descrambling -with the same key must reproduce the original body exactly. - -### `css_tab1_is_permutation` - -Verify the structure of TAB1: it is a substitution table used in key -mangling. Check that no two inputs map to the same output (TAB1 is a -permutation of 0..255). - -### `descramble_treats_high_bits_of_0x14_as_clear` - -Only bits 4-5 of byte 0x14 are the CSS scramble flag: the code reads -`sector[0x14] & 0x30 == 0` (bits 6-7, i.e. 0x40/0x80, are masked out by -0x30). A sector with 0x14 == 0x40 or 0x80 must therefore be treated as -UNSCRAMBLED and left byte-for-byte unchanged. This guards against a -too-wide mask silently "descrambling" (and thus corrupting) clear data. - -Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in -bits 4-5; the masked value 0 means not scrambled. -Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be -seen as scrambled and the body would change. - -### `descramble_triggers_on_either_flag_bit` - -Each individual scramble bit (4 and 5) independently marks the sector as -encrypted: 0x10 and 0x20 must both trigger descrambling. - -Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero. -Mutation: change `!= 0` early-return condition to `== 3` -> a sector -flagged only 0x10 or 0x20 would be skipped and left scrambled. - -### `descramble_clear_preserves_high_bits_of_0x14` - -After descrambling, ONLY the two scramble bits are cleared (`& 0xCF`); -bits 6 and 7 of byte 0x14 must be preserved. A sector with 0x14 == 0xF0 -becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00. - -Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0. -Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high -bits assert fails. - -### `descramble_leaves_header_and_seed_intact` - -The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the -header) must NOT be modified by the keystream — except byte 0x14 whose -flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which -live inside the header) must survive untouched, since the descrambler -reads them but never writes them. - -Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices -128..2048 only. -Mutation: change `.skip(128)` to `.skip(0)` -> header bytes (incl. the -seed) get XORed and this fails. - -### `descramble_covers_final_body_byte` - -The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix. -With a constant body and constant key, the keystream is non-degenerate -enough that the very last sector byte (index 2047) is altered. This guards -the loop bound `.take(2048)` against an off-by-one that would leave the -final byte(s) scrambled. - -Grounding: encrypted region end is 0x800 == 2048 (exclusive). -Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged, -assert fires (this body is all-zero so any keystream XOR shows). - -### `descramble_processes_the_first_sector_of_an_over_long_buffer` - -The length guard is a FLOOR, not a ceiling: `descramble_sector` is a -no-op below one sector, and processes the FIRST sector of anything at -least that long (the loop is `.take(2048)`). `css::descramble_sector` is -a public entry taking `&mut [u8]` of any length, so a caller handing it a -multi-sector buffer must get its first sector descrambled — a guard that -rejected over-long buffers would hand that caller its ciphertext back -unchanged, with the scramble flag cleared as if it had worked. - -### `descramble_output_depends_on_title_key` - -Descramble is keyed by `title_key XOR seed`: two different title keys -produce two different bodies for the same scrambled input. A cipher that -ignored the title key (or mixed it in wrongly) would yield identical -output — silent wrong-key decryption. - -Grounding: per-sector key = title_key[i] ^ sector[0x54+i]. -Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys -give the same body, assert fires. - -### `descramble_output_depends_on_seed` - -Descramble is keyed by the sector seed too: same title key, different -seed -> different body. Pins that bytes 0x54..0x59 actually feed the -keystream (not just the per-sector XOR key). - -Mutation: replace `seed` array reads with a constant -> both seeds give -the same body, assert fires. diff --git a/docs/lib-overview.md b/docs/lib-overview.md deleted file mode 100644 index 0faa0350..00000000 --- a/docs/lib-overview.md +++ /dev/null @@ -1,53 +0,0 @@ -# libfreemkv Crate Overview - -Overflow detail for the `src/lib.rs` crate-level doc comment, which is capped -by `ci/comment-guard.py`. Pointed to by the `//!` header there. - -## Unlocking - -Unlocking -- removing bus encryption (firmware unlock, AACS cert handshake, -CSS bus-auth) -- lives entirely in the `freemkv-unlock` crate; libfreemkv -consumes it privately and exposes none of it, so clients are oblivious to -unlockers (just as they are to the SCSI layer). - -## Why a live `disc://` can't go through `input()` - -A live `disc://` cannot be opened via [`input`] -- it returns -[`Error::DiscUrlNotDirect`] by design (use `Drive` + `Disc::scan` + -`DiscStream::new` directly for a live drive). Any file-backed source -(`iso://`, `m2ts://`) opens through [`input`]. - -## Architecture - -```text -Drive -- open, identify, unlock, read sectors - ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS) - ├── DriveId -- INQUIRY + GET_CONFIG identification - └── unlock_bridge -- private seam to the `freemkv-unlock` crate - (firmware / AACS cert / CSS bus-auth unlockers) - -Disc -- scan titles, streams, AACS state - ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions - ├── MPLS parser -- playlists → titles + clips + STN streams - ├── CLPI parser -- clip info → EP map → sector extents - ├── JAR parser -- BD-J audio track labels - └── AACS -- encryption: key resolution + content decrypt - ├── aacs -- KEYDB, VUK, MKB, unit decrypt - └── host_certs -- collect host certs (cert handshake lives in freemkv-unlock) -``` - -See also `docs/architecture.md` for the fuller design-level writeup. - -## AACS Encryption - -Disc scanning automatically detects and handles AACS encryption. -If a KEYDB.cfg is available (via `ScanOptions` or standard paths), -the library resolves keys and decrypts content transparently. - -Supports AACS 1.0 (Blu-ray) and AACS 2.0 (UHD, with fallback). - -## Error Codes - -All errors are structured with numeric codes. No user-facing English -text -- applications format their own messages. See `docs/error-codes.md` -for the full range table. diff --git a/docs/local-file-sink.md b/docs/local-file-sink.md deleted file mode 100644 index 3de26ce4..00000000 --- a/docs/local-file-sink.md +++ /dev/null @@ -1,34 +0,0 @@ -# `LocalFileSink` - -`BufWriter<File>`-backed sink for the common local-disk case -(`src/io/sink/local_file.rs`). - -## Buffering - -4 MiB internal `BufWriter`. Sized to coalesce the small per-PES writes -that come out of the muxer into kernel-page-aligned flushes without -making the buffer big enough to matter for memory pressure on a single -concurrent rip. - -## Seek-flush ordering - -`Seek` flushes the underlying `BufWriter` first; otherwise a seek could -leapfrog buffered data and silently corrupt the file. This is the same -shape `BufWriter` itself uses when it impls `Seek` in stdlib, and is -necessary for MKV's seek-back operations (cluster size patch, Cues -index, segment header backpatch) to land on the right offset. - -## Trait wiring - -[`SequentialSink`](../src/io/sink/mod.rs) is implemented explicitly -(not via a blanket impl) so its `finish()` flushes the `BufWriter` and -`fsync`s the file even when called through a `dyn` trait object; -`RandomAccessSink` is implemented over the `Seek` impl. - -## Construction contract - -Construction always opens the file `create + truncate + read + write`. -`read` is enabled so the same handle can be reused for a verification -re-read after the mux (the existing `FileSectorSink::create` pattern). -On Linux, `with_size_hint` additionally calls -`fallocate(FALLOC_FL_KEEP_SIZE)` to pre-reserve extents. diff --git a/docs/lpcm.md b/docs/lpcm.md deleted file mode 100644 index 432144be..00000000 --- a/docs/lpcm.md +++ /dev/null @@ -1,29 +0,0 @@ -# BD/DVD LPCM wire format - -BD LPCM PES packets (TS stream type 0x80) carry a 4-byte header on the -elementary-stream payload: - -``` -Bytes 0-1: audio frame number -Byte 2: reserved -Byte 3: quantization (bits 7-6), sample rate (bits 5-4), channel assignment (bits 3-0) -``` - -This header is part of the ES payload, so the BD parser must strip it. - -DVD LPCM lives in private stream 1 (sub-stream 0xA0-0xA7). Its 7-byte -private sub-header (sub_id + frames + first-access-unit-ptr(2) + emphasis + -quant/freq + channels) is stripped by `PsDemuxer` while demuxing the -Program Stream. By the time a DVD LPCM `PesPacket` reaches this parser its -`data` is already raw PCM, so the parser must NOT strip any further bytes — -doing so drops one sample pair per PES and drifts the audio. - -The two origins are distinguished by the `strip_header` flag: BD = strip, -DVD = leave intact. The raw PCM data is otherwise one complete audio frame -per PES; no framing is needed. - -For MKV: both BD and DVD LPCM map to codec ID "A_PCM/INT/BIG" (big-endian). -DVD-Video LPCM is big-endian per the DVD-Video spec, and `mkv.rs` emits -"A_PCM/INT/BIG" unconditionally for `Codec::Lpcm` — there is no DVD/BD branch -and no "A_PCM/INT/LIT" path, so no byte-swap or alternate codec ID applies. -All frames are keyframes (uncompressed audio). diff --git a/docs/m2ts-mux.md b/docs/m2ts-mux.md deleted file mode 100644 index 150c952e..00000000 --- a/docs/m2ts-mux.md +++ /dev/null @@ -1,119 +0,0 @@ -# m2ts_mux — standard MPEG-TS muxer - -## Wiring - -Single program (PMT PID `0x1000`, program number `1`): - - PAT on PID `0x0000` - - PMT on PID `0x1000` - - Video (HEVC, stream_type `0x24`) on PID `0x0100` - - First audio (AC3, stream_type `0x81`) on PID `0x0101` - (or TrueHD, stream_type `0x83`, if hinted) - -## Scope vs. full TS - -This is a deliberately minimal viable muxer: - - One program, one video track, optionally one audio track. - - PAT + PMT re-emitted every `PSI_INTERVAL_PACKETS` packets so a - mid-stream receiver can lock on within ~100 ms at typical - UHD bitrates. - - PCR clock derived from video PTS (`pts - PCR_LEAD_90KHZ`), - attached to the video PID's adaptation field every - `PCR_INTERVAL_PACKETS` packets. - - No language / descriptor tags, no SCTE-35 markers, no per-PID - PMT version bumps, no SDT/EIT. Sufficient for a conformant - demuxer to play this back, not for full broadcast deployment. - -## `base_relative_pts` - -Convert input PTS (nanoseconds) to 90 kHz ticks rebased on the stream's PTS -origin. The origin is seeded ONLY by the first video frame -(`may_seed_base == true`); audio frames never seed it. This keeps the -audio/video offset intact: a leading audio frame can't pull the base up and -collapse the first/lowest-PTS video frame to 0. Frames earlier than the base -saturate to 0. - -## `write_pes` packet-size math - -Emit one PES payload as a chain of TS packets on `pid`. If `pcr` is provided -the first packet carries an adaptation field with the PCR. PAT/PMT are -re-emitted every `PSI_INTERVAL_PACKETS`. - -TS = 188 bytes, header = 4 bytes, so 184 B remain after the header for the -adaptation-field area plus the payload area. With AF body of `b` bytes and -`s` stuffing bytes: AF total = `1 + b + s` (the leading `1` is the -`adaptation_field_length` byte itself). Payload = `184 - (1 + b + s)`. With -no AF area at all: payload = `184`. - -The fit-the-tail logic on the last packet of the PES uses stuffing rather -than a separate small packet, which is the standard MPEG-TS convention. - -## Test notes - -### `audio_pes_header_is_byte_exact_and_bounded_unlike_video` - -Golden vector for an audio PES header (ISO/IEC 13818-1 §2.4.3.7). - -Audio and video differ in TWO fields that no downstream check in this module -distinguishes: the stream_id, and whether `PES_packet_length` is filled in or -left at the unbounded 0x0000 form. A receiver reads the length to find the -end of the access unit without scanning for the next start code, so an audio -PES that borrowed the video form (or emitted a stub header) still tiles into -valid-looking 188-byte packets and only fails inside a decoder. - -### `absent_or_unparseable_codec_private_is_reported_not_silent` - -The parameter-set prepend is attempted once, on the first keyframe, and the -latch is armed whether or not it produced anything. When it produced -nothing — no `codec_private` was set, or the `hvcC` will not parse — the -emitted TS carries no VPS/SPS/PPS at all and its video cannot be decoded, yet -`finish()` returns `Ok`. That must not be silent (same defect, and the same -fix, as the BD-TS sibling `tsmux.rs`): the muxer now warns and, so a caller -can act on it, exposes `parameter_sets_emitted()`. - -Mutation check: drop the `params_emitted` bookkeeping and the two arms are -indistinguishable to any caller. - -### `pcr_restamped_mid_pes_within_interval` - -Regression: PCR must be re-stamped MID-PES, not only at PES boundaries. A -single large video frame (one PES) spans far more than PCR_INTERVAL_PACKETS -TS packets — a UHD I-frame. PCR-bearing video packets must recur at least -every PCR_INTERVAL_PACKETS video packets across that one PES; before the fix -only the PES's first packet carried PCR, leaving a multi-second clock gap -for the whole frame. - -## `packet.rs` — single-packet builder + writer - -`Packet` and `PacketWriter` are internal helpers (`pub(super)`) for this -module: `Packet` exposes raw byte layout so `mod.rs` can compose PSI / PCR / -PES bytes without each caller re-implementing the 188-byte boundary math; -`PacketWriter` writes each assembled packet straight through. - -### `Packet::set_header` - -Writes the 4-byte TS packet header from: `pid` (13-bit PID), -`payload_unit_start` (first packet of a PES / PSI section), `has_payload` -(packet carries any payload bytes), `has_adaptation` (packet carries an -adaptation field), `cc` (4-bit continuity counter). - -### `Packet::append_adaptation` - -`body` is the adaptation field body (flags byte plus optional PCR and so -on); `stuffing` is the number of `0xFF` stuffing bytes to append after the -body. The first byte of the field (`adaptation_field_length`) is computed -from `body.len() + stuffing`. - -Returns `Error::M2tsPacketMalformed` if the computed -`adaptation_field_length` would exceed `MAX_AF_LEN` — the length byte and -the bytes actually written must always agree, so an over-long field is -rejected rather than written with a clamped (and therefore lying) length -byte. - -### `PacketWriter::write_packet` / test `flush_delivers_the_buffered_packets_to_the_sink` - -`PacketWriter` adds no buffering of its own, so the sink's buffer is the -only one — and `flush()` is the only thing that empties it. Skipping it -truncates the transport stream mid-packet: the final 188-byte packets never -reach the file, so the last PES of the title is lost and the stream ends on -a partial packet (ISO/IEC 13818-1 §2.4.3.2 requires whole 188-byte -packets). diff --git a/docs/m2ts.md b/docs/m2ts.md deleted file mode 100644 index 3bdf093a..00000000 --- a/docs/m2ts.md +++ /dev/null @@ -1,25 +0,0 @@ -# m2ts test notes - -## h264_avcc_parameter_sets_are_emitted_as_annex_b - -An H.264 track's codec_private is an **avcC** record, not hvcC. The BD-TS -muxer must parse it with the avcC parser and emit the SPS/PPS as Annex-B -parameter sets, or the H.264 elementary stream reaches the player with no -SPS/PPS at all and is undecodable — silently, because frame_count still -advances and the mux reports success. - -Mutation: parse codec_private with hvcc_to_annex_b (the pre-fix behaviour) -> -the parser returns None, no parameter sets are emitted, and this fails. - -## vc1_video_is_wired_to_the_non_nal_path - -`M2tsStream::create` must opt a VC-1 video track OUT of Annex-B conversion. - -This pins the WIRING in `create`, not just `TsMuxer`'s flag: deleting the -`set_video_codec` loop leaves every TsMuxer-level test passing, because those -drive the muxer directly and set the flag themselves. Only a test that goes -through `create` catches it — and mangling MPEG-2/VC-1 video is silent, since -frame_count still increments and the mux reports success. - -Mutation: remove the `set_video_codec` loop from `create`, or make it declare -Vc1 as a NAL codec -> the ES gains a start code and this fails. diff --git a/docs/meta-sink.md b/docs/meta-sink.md deleted file mode 100644 index 5649b743..00000000 --- a/docs/meta-sink.md +++ /dev/null @@ -1,35 +0,0 @@ -# `meta_sink` — `json://` document construction - -## `title_json` — single serialization source of truth - -The per-title / per-stream schema comes from the normalized [`TitleProfile`] -(the same typed view `Disc::profile` exposes), serialized directly — -`json://` and any downstream consumer share one schema rather than a -hand-built one that can drift. - -**Schema note.** This replaces the previous hand-built `streams` array. The -stable editorial meaning is preserved, but the shape is normalized: the one -`streams` array (tagged with `kind`) becomes three typed arrays (`video` / -`audio` / `subtitles`); subtitle `qualifier` becomes the booleans `forced` + -`sdh`; audio `purpose` becomes `commentary` + `descriptive`; and per-track -`default` is now hoisted (first non-secondary video/audio). Per-stream fields -the normalized profile intentionally omits (`pid`, `width`/`height`, -`color_space`, `measured_cicp`, `sample_rate`, `channel_count`, -`mvc_dependent`) are no longer emitted; they live on the richer scan model, -not on this format-agnostic view. - -`json://`-only extras that are NOT part of the normalized profile — the -title's `clips` and the full `chapter_marks` list — are spliced back on so -those consumers do not regress. (`chapters` from the profile is the marker -COUNT; the full list is under `chapter_marks`.) `json://` is a per-title sink -with no disc context, so `index` is `0` and `is_main` is `false`; the -disc-level [`crate::disc::DiscProfile`] populates those correctly. - -## Write-only sinks: `read()` semantics - -`chapters://` and `json://` are write-only sinks: the whole file is emitted -at `create()` and there is nothing to demux back. `read()` returning -`Ok(None)` instead of the write-only error would make a caller that pointed -a mux INPUT at one of these URLs see a clean empty stream — the exact shape -of the shipped "empty title, exit code 0" defect. It must refuse with the -numeric code `E_STREAM_WRITE_ONLY`. diff --git a/docs/mkb.md b/docs/mkb.md deleted file mode 100644 index 0292836a..00000000 --- a/docs/mkb.md +++ /dev/null @@ -1,69 +0,0 @@ -# AACS MKB internals — relocated rationale - -Supplementary notes for `src/aacs/mkb.rs` comments that exceed the -comment-guard's internal-comment cap. Each section is pointed to by a -short `// See docs/mkb.md — <topic>` comment at the relevant call site. - -## Record-walker consolidation (`mkb_records`) - -Yields `(offset, rec_type, rec_len)` for each record — a 4-byte header -(type byte + big-endian 24-bit length) then the body — stopping at the -`00 000000` end marker or a malformed/out-of-bounds length. It is lazy -(no body clone), so a find-one-record caller never materialises the -multi-MB cvalue table. `walk_mkb` and every MKB record walk in -`aacs::resolve`/`aacs::derive` are built on this, so the framing rules — -and any future fix to them — live in exactly one place. They had -previously drifted across six hand-rolled copies before being -consolidated here. - -## cvalue record selection (`mkb_find_cvalues`) - -The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS 1.0 -and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the 5-byte -Subset-Difference index entries in record `0x04` — the standard AACS MKB -layout. - -On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full -subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with the -giant `0x04` index), while record `0x07` (Explicit Subset-Difference -Record) is a much smaller structure (~96 entries) and is NOT the cvalue -table. An earlier version of this function preferred `0x07`, which -under-tested the Subset-Difference walk on UHD discs and prevented the -DK→walk path from ever finding the matching uv. The selection MUST -therefore be `0x05`-first; `0x07` is only a fallback for -malformed/legacy MKBs that somehow lack a `0x05` record. - -## BE24 length field test (`mkb_records_honors_the_high_byte_of_the_be24_length`) - -The record length is a big-endian **24-bit** field, so the high byte -carries lengths of 64 KiB and up. The MKB records that matter most are -exactly that size — a real UHD cvalue table is `46_101 * 16` bytes and a -`0x2d` variant record is ~92 KiB — so a walker that dropped the high byte -would mis-frame every record of a real MKB from the first big one -onward, and every downstream key lookup would read the wrong bytes. - -(The pre-existing high-byte test used total length `0x0110`, whose high -byte is ZERO — it exercised the middle byte only. This test puts a -non-zero value in the high byte.) - -## Header-only record test (`mkb_records_yields_a_header_only_record_at_the_buffer_end`) - -`rec_len == 4` is a well-formed HEADER-ONLY record (the minimum the -walker accepts), including one sitting at the very end of the buffer -with no bytes after it. Rejecting either — the `pos + 4` bound or the -`rec_len < 4` floor being off by one — silently drops the MKB's last -record, and "the record isn't there" is indistinguishable from "the disc -doesn't carry it". - -## MKBType raw-bytes test (`mkb_type_raw_reads_all_four_body_bytes`) - -`mkb_type_raw` reports the 32-bit MKBType field verbatim (`[C]` -§3.2.5.1.1 Table 3-2), including a value this build does not recognise — -the caller uses it to tell "unknown MKB generation" from "no Type record -at all". All four bytes must come from the record body; reading any of -them from the wrong offset yields a type that silently classifies as a -different AACS generation. - -The recognised constants all share bytes with the `0x10` record-type -header byte (e.g. `MKB_21_CATEGORY_C` is `48 15 10 03`), so this test -uses a value with four distinct bytes, none of them `0x10`. diff --git a/docs/mkv-mux.md b/docs/mkv-mux.md deleted file mode 100644 index 38ae3ba3..00000000 --- a/docs/mkv-mux.md +++ /dev/null @@ -1,592 +0,0 @@ -# MKV muxer internals (`src/mux/mkv.rs`) - -Overflow rationale relocated here by `ci/comment-guard.py` (long-form prose -that would otherwise exceed the internal-comment cap). Each section is -pointed to from the corresponding `//` comment in the source. - -## Blu-ray 3D MVC BlockAddition mapping - -`BLOCK_ADD_ID_TYPE_MVCC` is the `mvcC` BlockAddIDType — the -`MVCDecoderConfigurationRecord` fourcc, big-endian ASCII `'m''v''c''C'`. -Matroska BlockAdditionMapping/BlockAddIDType for a Blu-ray 3D MVC -configuration (RFC 9559 + Matroska Codec Specifications §4.1.5; equals -ISO/IEC 14496-15 `MVCConfigurationBox('mvcC')`). The per-frame dependent -(right-eye) view rides as a BlockAdditional under this mapping. - -## MVC decoder config record layout - -`mvc_decoder_config_record` builds an `MVCDecoderConfigurationRecord` -(ISO/IEC 14496-15:2013 §7.6.2) from the dependent view's subset-SPS (NAL -type 15) and PPS NAL units. This is the `BlockAddIDExtraData` for the -`mvcC` BlockAdditionMapping (and, in the ISO container, the -`MVCConfigurationBox('mvcC')` payload). - -Layout mirrors the AVCDecoderConfigurationRecord except `byte[4]` -repurposes the AVC record's `bit(6) reserved` as -`complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`. -`profile`/`compat`/`level` describe the WHOLE MVC stream and come from the -subset SPS (bytes 1..=3). `length_size_minus_one` MUST match the base avcC -(freemkv always emits 4-byte length prefixes → 3). SPSs precede subset SPSs -in the array; here the array carries the dependent view's parameter sets. - -Returns `None` if either param set is absent, too short, or exceeds the -16-bit length field (a param set > 65535 bytes is non-conforming and would -mis-frame the record). - -## MVC CodecPrivate layout - -`mvc_codec_private` builds the `CodecPrivate` for an MVC (Blu-ray 3D) base -track: the base view's `AVCDecoderConfigurationRecord` (`avcc`) followed by -an `mvcC` extension block, per the Matroska Codec Specifications §4.3.9: - -```text -avcC ‖ u32be(extension_block_size − 4) ‖ "mvcC" ‖ MVCDecoderConfigurationRecord -``` - -The size field is the extension block length **excluding the 4-byte size -field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level -MVC signal that decoders and media analyzers read (the per-frame -`BlockAdditional` under the `mvcC` BlockAdditionMapping carries the -dependent view's data). A plain (2D) track never calls this — it writes its -`avcc` verbatim. - -## CICP colour resolution precedence - -`cicp_for_video` resolves a video stream's CICP colour code points — -`(matrix, transfer, primaries, range)`, ITU-T H.273 — using a single -precedence so EVERY sink (the MKV muxer here AND the FVI sidecar in -`videomap.rs`) agrees and can never drift: - -1. **Measured CICP** read from the bitstream (HEVC/H.264 VUI - `colour_description` or MPEG-2 `sequence_display_extension`) is - AUTHORITATIVE — copied through verbatim when present. -2. Otherwise fall back to the coarse `color_space` enum (a playlist nibble / - PAL-NTSC guess), THEN apply the HDR-driven transfer override: BT.2020 - only appears on HDR UHD, where the real transfer is PQ (16) for - HDR10/HDR10+/DV or HLG (18) for HLG — never the SDR transfer 14 the enum - alone would emit. - -## HDR10 unit conversions and SEI primary order - -`write_hdr10` emits the HDR10 static-metadata children of the Matroska -`Colour` element: `MasteringMetadata` (chromaticity / luminance floats) -plus `MaxCLL` / `MaxFALL` (uints). Called ONLY when the metadata was -measured from the bitstream SEI, so it is never written for SDR content. - -Unit conversions (Rec. ITU-T H.265 D.3.28 → RFC 9559 / Matroska): -- chromaticity: SEI integer × 0.00002 → Matroska float in [0, 1] -- luminance: SEI integer × 0.0001 → Matroska float in cd/m² -- MaxCLL / MaxFALL: already cd/m² integers → written as uints verbatim - -The SEI primary order is c=0 Green, c=1 Blue, c=2 Red (D.3.28); the -`Hdr10Metadata` arrays preserve that SEI order, so index 0 → G, 1 → B, -2 → R is mapped onto the Matroska R/G/B element layout here. - -## Language element default rationale - -`language_or_und` is the Matroska `Language` element the muxer writes for -a stream whose source reported `lang`. RFC 9559 §12 defines the element as -an ISO 639-2 code and gives no meaning to an empty one; the code for "no -language stated" is `und`, and that is what a source with no language -table (the HD-DVD EVO stream probe, a Blu-ray STN slot with no language -bytes) has to emit. The element is written unconditionally by -`MkvMuxer::new`, so this is the one place that decides it — a source-side -default would have to be repeated in every scanner and would still leave -the muxer able to ship an invalid file. - -## Why BlockGroup buffering avoids per-frame seeks - -`MkvMuxer::block_group_buf` is a reusable scratch buffer for assembling -ONE BlockGroup before it is written with a single `write_all`. - -The BlockGroup path is the hot one — the MPEG-2 parser stamps a per-frame -duration, so every I/P/B frame lands there, plus AC-3 audio and PGS -subtitles (~350k BlockGroups for a 90-minute feature). Building the -element in memory means its size is known before it reaches the file, so -no `start_master`/`end_master` back-patch is needed: that removes the -per-frame `stream_position()` + two `seek()`s, each of which flushed the -4 MiB `BufWriter` (`BufWriter` does not override `stream_position`, so a -position query is `seek(Current(0))` = flush + lseek) and reset the -writeback pipeline's `last_flush_pos`. Kept on the muxer so the allocation -is made once, not per frame. - -## dropped_pre_cluster logging rationale - -`MkvMuxer::dropped_pre_cluster` counts frames handed to `write_frame` that -were dropped because no cluster was open yet (a cluster only opens on a -keyframe from the primary video track, whatever index that is — not -necessarily track 0). See `write_frame` for the cluster-driver invariant. - -The ALL-dropped case is surfaced as an error by `finish()`, but via -`frame_count == 0`, not via this counter. A PARTIAL drop — leading audio / -subtitle frames ahead of the first video IDR, or an M2TS whose PMT lists -audio before video — is normal enough not to fail the mux, but it used to -leave NO record anywhere: the field was incremented at two sites and read -nowhere (no log, no error, no accessor), so those frames vanished from the -output with `completed = true`, an empty `undelivered_streams`, and -nothing in the log. `finish()` now logs the count — that log is the -field's only reader, so do not delete it and turn this back into dead -bookkeeping. - -## TimestampScale choice - -`TIMESTAMP_SCALE_NS`: nanoseconds per Matroska timestamp tick, 0.1 ms -(100_000 ns). - -The classic 1 ms scale truncates two distinct cadences onto the same tick: -- 23.976 fps video frames are ~41.7 ms apart, but with B-frame reorder two - neighbouring frames can round to the same whole millisecond — a decoder - then derives colliding DTS ("non monotonically increasing dts"). -- TrueHD audio access units are 0.833 ms (1/1200 s); at 1 ms granularity - every AU truncates to a 1 ms grid and the per-track monotonic nudge has - to space them at a fabricated 1 ms instead of their true 0.833 ms. - -0.1 ms resolves both: 41.7 ms and 0.833 ms each map to distinct ticks, so -frames stop colliding and audio keeps its real cadence. Player/parser -support for sub-millisecond TimestampScale is universal (it is the spec -default mechanism). The cost is a smaller per-cluster i16 span (see -`MAX_BLOCK_REL`), handled by splitting clusters and emitting a Cue for the -split (see `write_frame`). - -## cluster duration vs. i16 block-relative limit - -`CLUSTER_DURATION_TICKS`: nominal new-cluster interval (2 s) expressed in -TimestampScale ticks. - -A keyframe only OPENS a new cluster once this much has elapsed since the -open cluster's timestamp, so the actual cluster span runs from this value -up to roughly this value plus one GOP (the next keyframe lands a GOP -later). With a typical ≤ 1 s GOP that worst-case span (~3 s ≈ 30_000 -ticks) stays UNDER the i16 block-relative limit (`MAX_BLOCK_REL` = 32_767 -ticks ≈ 3.27 s at the 0.1 ms scale), so video keyframes drive Cue-aligned -cluster boundaries and the i16-overflow split path stays a rare fallback -(long audio-only stretches or pathological multi-second GOPs) rather than -the common case. The classic 5 s window would, at this scale, force an -unaligned i16 split inside every cluster. - -## MAX_BLOCK_REL / MIN_BLOCK_REL rationale - -`MAX_BLOCK_REL` is the maximum block-relative timestamp expressible in the -signed 16-bit SimpleBlock/Block field (`i16::MAX` ticks). A frame whose -offset from the open cluster's timestamp falls outside -`i16::MIN..=i16::MAX` ticks forces a new cluster (see `write_frame`) so -the `as i16` cast can never wrap — in EITHER direction. PES timestamps -come from untrusted disc/file bytes and can back-jump on discontinuities, -so the lower bound matters as much as the upper one. At a 0.1 ms scale -i16::MAX is ~3.27 s, well under the 5 s cluster window, so a long-GOP / -audio-only stretch can hit this bound before the keyframe boundary — the -split path must (and does) push a Cue. `MIN_BLOCK_REL` is the -corresponding lower bound (`i16::MIN`). - -## monotonic_ts non-monotonic DTS rationale - -`monotonic_ts` forces a per-track block timestamp (in TimestampScale -ticks) to be strictly later than the previous one written for that track. -`prev` is the last timestamp for the track (`None` for the first frame). -Fixes non-monotonic DTS: some audio PES PTS truncate to the same tick as -the prior frame (or tick back one from rounding), which strict -players/decoders reject. At the 0.1 ms scale a TrueHD AU (0.833 ms = ~8 -ticks) no longer collides with its neighbour, so this rarely fires for -lossless audio — but a +1-tick nudge (0.1 ms, sub-AU and inaudible) still -guards genuine same-tick collisions on any no-reorder track. Never moves a -timestamp earlier. - -## block_ts rationale - -`block_ts` computes the per-track block timestamp. The strictly-monotonic -nudge is applied to AUDIO/SUBTITLE tracks only; ALL VIDEO tracks are -returned UNCHANGED. - -With B-frames, a video frame's presentation PTS is legitimately -non-monotonic in decode/storage order (a B-frame sits between its -anchors, below the frame stored just before it). Forcing it -strictly-increasing clobbers those PTS to prev+1ms — a `copy` remux -preserves the (wrong) value, but a decoder derives DTS from the HEVC POC -and finds them colliding ("non monotonically increasing dts", thousands -per title). Matroska SimpleBlock permits non-monotonic block timestamps -(signed block-relative offsets), so video keeps its true PES PTS; only -no-reorder tracks (audio, subtitles), where a same-millisecond collision -IS a real defect, get nudged. - -The exemption is keyed on `is_video` (track type), NOT a track index: a -title can carry more than one video track — e.g. a Dolby Vision -enhancement layer at index 1 — and every one must keep its true PTS. -Keying on `track_idx == 0` clamped the EL and reintroduced the exact -non-monotonic-DTS warning this exemption exists to prevent. - -## track_vint widths - -`track_vint` encodes a Matroska track number as an EBML VINT into a stack -buffer, returning the buffer and the used length. Track numbers are small -(1-based, a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers -`< 0x4000`, and 3 bytes covers `< 0x20_0000`; no heap allocation, called -once per block on the mux hot path. - -Each width uses a marker bit that must NOT collide with the payload's top -byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40 -(14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all -three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range -track number can never silently clobber the marker bit and corrupt the -block. Real discs never approach even the 2-byte range; the 21-bit -ceiling is an absurd upper bound kept as a `debug_assert`. - -## codecPrivate path history - -Pre-0.13 had a deferred codecPrivate path (placeholder + seek-back fill) -for video, but the PES pipeline hands it up-front via `DiscTitle`, so that -path was never exercised — removed in the 0.13 dead-code sweep. - -## set_clips seam-correction rationale - -`MkvMuxer::set_clips` drives seam correction from the title's PlayItem -marks instead of inferring it from PTS jumps. - -A multi-clip Blu-ray playlist joins its clips with overlaps and skips -that PTS inspection cannot recover: a forward jump is indistinguishable -from frames lost to damaged media, and an overlap smaller than the -reorder threshold is invisible. Given the marks, each clip is placed at -the sum of the earlier clips' durations, so the output runs exactly as -long as the playlist says the title is. - -No-op for a title with fewer than two clips or without usable marks — -DVD, HD-DVD and file sources keep the inference path. - -## write_frame_at seam/MVC BlockAdditional rationale - -`write_frame_at` is `write_frame` (test-only convenience wrapper) with -the frame's SOURCE BYTE OFFSET. - -Under a seam plan that offset identifies which clip the frame came from -by lookup rather than by inferring it from the timestamp — which is -ambiguous inside an overlap, where two clips' mark ranges both contain -the same instant. Callers that have a `PesFrame` should pass -`frame.source.map(|s| s.byte)`; `write_frame` is the same call with no -provenance, which falls back to the mark heuristics. - -`block_additional`, when `Some`, is attached to the frame as a Matroska -`BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the -Block and the dependent (right-eye) access unit rides as the -BlockAdditional under the track's `mvcC` mapping. Such a frame is always -a `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is -not a keyframe. `None` for every non-3D frame. - -## finish() cluster-driver invariant - -`MkvMuxer::finish` finishes the MKV file: writes the Cues element. - -A cluster only opens on a keyframe from the PRIMARY VIDEO TRACK — the -first track whose type is video, at whatever index it occupies (see -`cluster_driver`, which is `primary_video_track.unwrap_or(0)`; index 0 is -only the fallback for a file with no video track at all). The caller must -deliver a keyframe on that track before (or alongside) other-track data. -This said "track-0" and required the caller to place video at index 0, -which the code has never actually required — so a reader debugging -dropped frames would suspect track ordering, which is not it. If no such -keyframe ever arrives, every `write_frame` is silently dropped; rather -than emit a structurally valid but empty MKV (zero clusters, zero -frames), `finish` returns `Error::MkvInvalid` when frames were submitted -but none were written. - -## write_block_group ReferenceBlock/keyframe-bit - -`write_block_group` writes a BlockGroup (Block + BlockDuration, plus a -ReferenceBlock when the frame is not a keyframe). - -`reference` is `Some(offset_ticks)` for a non-keyframe and `None` for a -keyframe. Inside a BlockGroup the SimpleBlock `0x80` keyframe bit is -reserved and MUST be 0, so a non-keyframe that omits ReferenceBlock is -indistinguishable from an intra frame. This path is NOT subtitle-only: -the MPEG-2 parser stamps a per-frame duration, so every MPEG-2 video -frame (I, P and B) arrives here. - -## write_block_group_mvc reference/duration - -`write_block_group_mvc` writes a BlockGroup carrying the base view Block -plus the MVC dependent (right-eye) access unit as a `BlockAdditional` -(BlockAddID=2), per the track's `mvcC` BlockAdditionMapping. A -non-keyframe frame gets a `ReferenceBlock` (`reference` = referenced -keyframe offset in ticks) so it is not mistaken for a seek point; -`BlockDuration` is written when known. - -## removed parse_resolution/parse_sample_rate/parse_channels history - -Old `parse_resolution`/`parse_sample_rate`/`parse_channels` helpers were -removed — `Resolution::pixels()`, `SampleRate::hz()`, -`AudioChannels::count()` replace them. - -## keyframe_survives_roundtrip regression history - -`keyframe_survives_roundtrip_for_duration_bearing_frames` asserts keyframe -flags survive a write→read round-trip for frames that carry a per-frame -DURATION, i.e. the BlockGroup path. - -Regression: every MPEG-2 frame carries a duration (the parser stamps one -on I, P and B alike), so ALL MPEG-2 video is written as BlockGroup, not -SimpleBlock. Inside a BlockGroup the SimpleBlock `0x80` keyframe bit is -reserved and the writer emits 0 — keyframe-ness lives ONLY in the -presence/absence of a ReferenceBlock. Two halves were broken: - - the writer discarded `keyframe` on this path (no ReferenceBlock ever), - so a shipped DVD rip marked every P/B frame as a seek point; - - the reader ignored ReferenceBlock and read the always-0 reserved bit, - so EVERY BlockGroup frame read back as a non-keyframe. - -Downstream that silently dropped all video on `mkv://`(MPEG-2)→`m2ts://` -(TsMuxer drops non-key video until the first keyframe) and made -`mkv://`→`mkv://` / `stdio://` fail E6008 (MkvMuxer opens a cluster only -on a track-0 video keyframe → zero frames written). - -A SimpleBlock case (duration `None`) is asserted alongside so a fix that -regresses the non-duration path is caught too. - -## mkvstream preroll test - -`mkvstream_preserves_video_keyframe_after_audio_preroll` runs end-to-end -through the REAL `MkvStream::create`/`write`/`finish` path (deferred -Pending→activate buffering), in the shape the DVD pipeline actually -produces: several AC-3 audio frames arrive BEFORE the first video frame -(audio emits per-PES immediately, while MPEG-2 holds its first GOP), and -the video frame carries a per-frame DURATION so it is written as a -BlockGroup. - -Guards the same regression as -`keyframe_survives_roundtrip_for_duration_bearing_frames`, but across the -buffering machinery rather than the bare muxer — the video keyframe must -still be a keyframe after being buffered and replayed on activation. - -## frames_dropped_before_first_cluster_are_counted rationale - -Frames dropped before the first cluster opens must be COUNTED, and the -count must survive to `finish()` so it can be reported. The counter was -incremented at two sites and read nowhere — no log, no error, no -accessor — so a partial drop (leading audio/subtitle frames ahead of the -first video IDR, or an M2TS whose PMT lists audio before video) silently -omitted those frames from the output while the run reported -`completed = true` with an empty `undelivered_streams` and nothing in -the log. `finish()` now logs it; this pins the accounting the log -depends on. - -## zero_cues_voids_seekhead_entry rationale - -When no Cues element is written (zero cue points), the SeekHead must NOT -retain a CUES entry that back-patches to the Cues offset — that offset -now holds Tags / EOF, a dangling pointer to a non-Cues element. finish() -Voids the unused CUES Seek entry instead. (The empty-cues case is -defensive — the normal path pushes a cue with every cluster — so the -test clears the cue list directly before finalizing.) - -## second_video_track_pts_not_clobbered rationale - -Regression for the second-video-track bug: a Dolby Vision enhancement -layer is video but NOT track 0. The exemption must follow track TYPE, so -the EL's B-frame PTS are preserved exactly like the main video's — not -clamped to prev+1ms (which reintroduced the non-monotonic-DTS flood on -the EL stream). Drives the muxer through both video tracks and asserts -every video block timecode equals its source PTS. - -## clip_boundary_with_straggler test rationale - -End-to-end output regression (the symptom, at the block-timecode level): -a large clip-boundary reset WITH an interleaved straggler audio frame -from clip 1's tail, driven through the full muxer. Asserts cluster -timestamps are monotonic non-decreasing AND the timeline reaches past -the boundary (clip 2 present) without ratcheting. This is the test that -would have caught BOTH the original `-820000` non-monotonic band and the -straggler ratchet that made everything after the boundary unseekable. - -## epoch_driver_follows_primary_video rationale - -Regression for the hardcoded-`track 0` epoch driver: on an M2TS/PMT -title the PMT may list an AUDIO ES before the VIDEO ES, so `streams[0]` -is audio and the video is at index 1. The epoch driver must follow the -PRIMARY VIDEO track (first video index), not the literal 0. If audio -(index 0) drove epochs, its sparse/lagging PTS would ratchet the -frontier and false-trigger boundary resets, inflating the timeline. This -drives the muxer with audio=track0 / video=track1 and asserts cluster -timestamps stay monotonic and the timeline does NOT ratchet past the -real span. - -## dvd_two_letter_language_becomes_iso_639_2 rationale - -RFC 9559 §12 / the Matroska `Language` element spec restrict the legacy -`Language` element to the Matroska language form (ISO 639-2, three -lowercase letters), never ISO 639-1 (two letters). The DVD IFO -audio-attribute block itself carries a raw ISO 639-1 code (e.g. "en") on -disc — `ifo::parse_audio_attr` converts it to ISO 639-2 before returning, -via `ifo::dvd_lang_to_iso639_2`. This mimics the real DVD pipeline -(`disc/dvd.rs`'s `Stream::Audio` construction) end to end: real on-disc -IFO bytes -> `ifo::parse_audio_attr` -> `disc::AudioStream` -> -`MkvTrack::audio` -> the muxer -> the emitted `Language` element. - -## a_source_with_no_language_emits_und rationale - -A source that knows no language at all leaves `language` EMPTY, and the -muxer writes the `Language` element unconditionally — so an empty string -becomes a zero-length `Language` in the shipped file, which is not a -Matroska language form (RFC 9559 §12 wants three ISO 639-2 letters) and -is not the ISO 639-2 code for "unknown" either. - -This is the state every HD-DVD rip is in: `disc::hddvd`'s EVO stream -probe has no language table to read and sets `language: String::new()` -on every audio stream it finds. The DVD path normalises to "und" in -`ifo::parse_audio_attr`; the guard has to exist at the muxer too, which -is the one place every source funnels through. - -## dvd_unmapped_or_empty_language_becomes_und rationale - -An unmapped or absent DVD language code (bytes 0x00 0x00 in the IFO -attribute block) must degrade to the valid Matroska "undetermined" code -`und`, never to an empty string or a raw 2-letter code — both of which -violate the Matroska language form. - -## emitted_language_for_dvd_code - -Builds an 8-byte DVD IFO audio-attribute block (AC-3, 48 kHz, 6ch) -carrying `code` in the language bytes, runs it through the real DVD -pipeline (`ifo::parse_audio_attr` -> `disc::AudioStream` -> -`MkvTrack::audio` -> the muxer) and returns the value that actually lands -in the emitted Matroska `Language` element. - -## dvd_language_outside_the_menu_vocabulary rationale - -The ISO 639-1 -> ISO 639-2 conversion must cover the WHOLE of ISO 639-1, -not just the handful of languages that happen to appear in Blu-ray -menu-graphic filenames. A Region-2 disc routinely carries Romanian, -Bulgarian, Croatian, Serbian, Slovak, Slovenian, Hebrew, Estonian, -Latvian, Lithuanian, Icelandic and so on; if those all collapse to `und`, -every one of a disc's subtitle tracks emits the same `Language` value and -nothing else tells them apart (DVD streams carry an empty `label`). A -valid-but-identical code is worse for the user than the invalid one it -replaced, so each of these must reach the emitted `Language` element as -its own correct three-letter code. - -## dvd_era_language_aliases_map_to_the_modern_code rationale - -DVD-Video froze its language list on the 1988 edition of ISO 639-1, -which spelled Hebrew `iw`, Indonesian `in` and Yiddish `ji`. Real discs -authored to that list carry those bytes, so they must map to the same -ISO 639-2 codes as the modern `he` / `id` / `yi` spellings rather than -degrading to `und`. - -## unknown_dvd_language_still_yields_exactly_und rationale - -Widening the table must not weaken the degradation guarantee: a code -that is not ISO 639-1 at all still has to yield exactly `und`, a valid -Matroska language value, and never a passed-through two-letter code, an -empty string, or a guess. - -## mkv_pgs_wrong_forced_label_is_cleared rationale - -A wrong vendor forced label is CLEARED by the content — but only where -the content can carry that argument. During a full mux every display set -on the track is seen, so "this track has hundreds of display sets and -not one of them is forced" is as complete as evidence gets; and a -sibling track that does carry `forced_on_flag` proves the authoring -house sets it, so the absence on this track means something. - -Before this, `finish()` only ever promoted 0→1, so a track wrongly -labelled forced stayed forced in the output no matter what the disc -contained. - -## mkv_pgs_forced_label_survives rationale - -The guard that stops the above clearing from being reckless. On a disc -whose authoring never sets `forced_on_flag` — they exist — no track has -any forced display set, so "no forced display set here" is a fact about -the authoring, not about the track. The vendor label is then the only -information there is and must survive. - -## a_title_the_seam_plan_emptied audit finding - -`a_title_the_seam_plan_emptied_is_not_reported_as_a_skippable_stub` -asserts a seam-plan clip list whose marks exclude every frame must fail -with `SinkWroteNothing`, NOT `MkvInvalid`. - -Audit finding: both gates in `finish()` were dead code under test — -`set_clips` was never called anywhere in the suite, so -`continuity.dropped_total()` was always 0 and neither branch could be -reached. Swapping the two `frame_count == 0` checks left the whole suite -green, while a title the seam plan emptied came back as `MkvInvalid` — -which `is_skippable_title_stub` classifies as an empty nav/menu stub, so -an all-titles rip would silently omit a real feature and exit 0. - -## all_block_groups rationale - -`all_block_groups` parses EVERY BlockGroup in the output, in emission -order. - -A presence check like `find_id(data, ebml::REFERENCE_BLOCK).is_some()` -cannot express what actually matters here: ReferenceBlock's ID is the -single byte 0xFB, and finding one somewhere in the file says nothing -about WHICH blocks carry it. A mutant emitting a ReferenceBlock on every -BlockGroup — keyframes included — satisfies that check while making -every frame read back as a non-keyframe, which is the exact defect these -tests exist to guard. - -## dolby_vision_config dvcC bit-packing layout - -`dolby_vision_config` (dvcC) bit packing: byte 2 = profile(7 bits) << 1 | -level high bit; byte 3 = level low 5 bits << 3 | rpu | el | bl; byte 4 = -bl_compat_id << 4. - -## parse_bps_tags decode rationale - -`parse_bps_tags` parses the `Tags` master into `(TagTrackUID, BPS)` -pairs, decoding the BPS `TagString` back to a number. - -The previous assertion was -`String::from_utf8_lossy(&data).contains("800")` over the WHOLE file, -which a wrong BPS passes trivially — 80000 contains "800", and so does -any unrelated byte run that happens to spell it. The bitrate a media -player displays has to be the real one, so decode it. - -## seekhead_chapters_entry rationale - -The Chapters Seek entry has to be back-patched like every other one. It -is the only fixup whose offset comes from an `Option`, so it is the only -one that can quietly fall through to the `_ => 0` arm and ship a -SeekPosition of 0 — a chapter index a player resolves to the Segment -header. Only titles WITH chapters exercise it, and every SeekHead test -until now used a chapterless title. - -## video_track_survives_a_zero rationale - -`MkvTrack::video` divides by two disc-supplied numbers: the frame-rate -numerator and the display-aspect denominator. Both come off an IFO/MPLS -scan of a damaged disc, and a zero in either one is an arithmetic panic -that takes down the whole `autorip` service — not a bad file. The guards -exist; nothing proved they were load-bearing. - -## secondary_streams_are_never_the_default rationale - -FlagDefault marks the track a player selects with no user input. A -SECONDARY stream (a Dolby Vision enhancement layer, a director's -commentary) must never be it: default-selecting the DV EL shows a viewer -the wrong picture, and default-selecting a commentary track the wrong -audio. `is_default` is the inverse of `secondary` on both the video and -the audio builder; neither inversion was asserted. - -## subtitle_codec_id_distinguishes_vobsub_from_pgs rationale - -DVD subtitles are VobSub, Blu-ray subtitles are PGS, and the codec ID is -what tells a player which parser to hand the bitstream to. The subtitle -builder's fallback is PGS, so a lost `Codec::DvdSub` arm silently labels -every DVD subtitle track as PGS — a track that displays nothing. - -## hdr_format_overrides_the_transfer rationale - -The HDR transfer override is the only thing that turns the coarse -`color_space` nibble into a PQ/HLG code point. Every earlier test paired -HDR10 with BT.2020, whose enum mapping already yields PQ — so the -override itself was never observed. A disc whose playlist nibble says -BT.709 while the scan detected HDR10 (an ordinary UHD mis-tag) is the -case that separates them: without the override it ships transfer 1 (SDR -gamma) and the picture renders washed out. - -## mvc_decoder_config_record_boundaries rationale - -`mvc_decoder_config_record` frames a Blu-ray 3D parameter set with -16-bit length prefixes. Its bounds are exact: 4 bytes is the shortest -subset SPS it can read `profile`/`compat`/`level` out of, and 0xFFFF is -the largest length the field can express. Off by one at either end and a -valid 3D title silently loses its mvcC mapping (no 3D signal) or writes -a record whose declared length does not match its payload. diff --git a/docs/mkvstream.md b/docs/mkvstream.md deleted file mode 100644 index 9b1134b4..00000000 --- a/docs/mkvstream.md +++ /dev/null @@ -1,256 +0,0 @@ -# mkvstream internals - -Relocated rationale/history for comments in `src/mux/mkvstream.rs` that -exceeded the comment-guard's internal-comment cap. Each section is pointed -to from a short `// See docs/mkvstream.md#<anchor>` comment at its call site. - -## ebml-size-caps - -`MAX_BLOCK_SIZE`: largest accepted SIMPLE_BLOCK payload. A block is a small -vint track header + 2-byte rel-ts + 1-byte flags + one frame of elementary -data. UHD HEVC keyframes run a few MB; 64 MiB is generously above any real -single-frame block while still bounding a hostile allocation. - -## apply-coding-to-track - -Set a video track's `FieldOrder` from the MEASURED coding of the first coded -picture — the parser's value, the first time, never a guess. - -A progressive track — or a progressive picture on an interlaced-flagged track -— has no field order (left UNDETERMINED — expected); the latter case ALSO -clears the track's `interlaced` flag, since the declared scan type came from -the IFO/MPLS resolution and the measurement supersedes it. An INTERLACED -track that reaches here WITH a video picture but no measured field order is a -parser/source gap (MPEG-2 carries `top_field_first` on every interlaced -picture, so it should never be missing): LOG it loudly so the source can be -debugged, and leave UNDETERMINED — a muxer never fabricates a source fact. -`video_picture_seen == false` (an empty title finalized with no frames, or a -cap-triggered build that never saw the video frame) is NOT a defect — the -missing coding is expected there, so log it quietly. - -## track-table - -Read-side map from Matroska TrackNumber to the index of the corresponding -entry in `DiscTitle::streams`. - -RFC 9559 §5.1.4.1.1 constrains TrackNumber only to be non-zero ("range: not -0"); NOTHING in the specification requires the numbers to be `1..=N`, to be -contiguous, or to appear in ascending TrackEntry order. `parse_track` also -DROPS every TrackEntry whose TrackType this crate cannot carry (anything but -1/2/17 — e.g. a TrackType 18 buttons track), so the TrackNumber space and the -stream vector diverge for perfectly legal inputs. - -The reader used to derive the stream index as `TrackNumber - 1`, which routes -blocks to the WRONG stream (parsed by the wrong codec parser) or drops them -entirely. This table records the real TrackNumber for each retained stream, -in stream order, and is the only thing allowed to translate between the two. - -## split-lacing - -Split the body of a LACED (Simple)Block — the bytes after the flags octet, -beginning with the Lacing Head — into its individual frame payloads, per -RFC 9559 §10.3. - -`lacing` is the 2-bit LACING field value (`LACING_XIPH`, `LACING_EBML` or -`LACING_FIXED`). Returns `None` when the lacing header is malformed — the -frame boundaries are then unknown, and the caller MUST reject the block -rather than hand a concatenation of frames plus lacing header downstream as -though it were one frame (which is exactly the silent corruption this -function exists to end). - -The Lacing Head is "number of frames in the lace minus 1" on one octet, so -the frame count is bounded by 256 and no allocation here is attacker-scaled. - -## parse-block - -Parse a (Simple)Block payload into zero or more PesFrames. - -Zero frames means the block was SKIPPED — too short, track 0, or a -TrackNumber this file does not (retainedly) declare. More than one frame -means the Block was LACED (RFC 9559 §10.3): one Block legitimately carries -several frames, and handing the raw payload downstream as a single frame -feeds the codec parser a concatenation of frames plus lacing header. An -`Err` means the lacing header is malformed, so the frame boundaries are -unknowable — the block is rejected rather than mangled. - -`cluster_ts_ticks` is the open cluster's timestamp in TimestampScale ticks -and `ts_scale_ns` is that scale (ns per tick); the block PTS is computed as -`(cluster_ts_ticks + rel_ts) * ts_scale_ns` so foreign MKVs whose scale -isn't 1 ms are honoured (freemkv's own output uses 1_000_000 and round-trips -unchanged). `tracks` resolves the TrackNumber to a stream index; `duration_ns` -is propagated for BlockGroup blocks (None for SimpleBlock). - -## block-additions-dropped-test - -A BlockGroup's `BlockAdditions` subtree (BlockAddID=2 — the MVC -dependent/right-eye access unit this crate's 3D writer emits, see -`mkv.rs::build_block_group`) cannot be carried by `PesFrame`, so read-back -drops it. That is a LOSSY outcome, and this crate's rule is that a lossy -outcome is never silent. - -Regression: the arm did not exist, so the subtree fell into the `_ =>` -skip arm — an `mkv://` → `mkv://` re-mux of a 3D rip lost one whole eye -with no error, no warning and `lost_bytes == 0`, i.e. the mux reported a -clean, complete, loss-free copy of a file it had halved. The base view -must still read back intact, and the dropped payload must now be counted -so it reaches `MuxOutcome.lost_bytes` / `.errors`. - -## reference-block-keyframe-test - -A BlockGroup carrying a ReferenceBlock is NOT a keyframe — that element's -presence is the only non-keyframe signal a BlockGroup has (the SimpleBlock -0x80 flag bit is reserved and always 0 inside one). - -Regression: the reader used to `skip_bytes` past REFERENCE_BLOCK and read -the reserved bit instead, so EVERY BlockGroup frame came back as a -non-keyframe. Since the MPEG-2 parser stamps a per-frame duration, all -MPEG-2 video takes the BlockGroup path — so no video frame ever looked -like a keyframe on re-mux. That silently dropped all video on -`mkv://`→`m2ts://` and failed `mkv://`→`mkv://` with E6008. - -## ebml-lacing-test - -EBML lacing (RFC 9559 §10.3.3): the Lacing Head, the first frame's size as -an unsigned VINT, then each later size as a SIGNED VINT difference from -the previous one. Three frames of 3/4/5 octets must come out as THREE -frames with byte-exact payloads. - -Regression (silent corruption): the reader took the Block payload verbatim -and never looked at the LACING bits, so this Block became ONE 15-byte frame -whose first three bytes are the lacing header — garbage handed to the codec -parser with no error, and one timestamp for three frames. - -## track-number-gap-and-sub-44100-sampling-test - -RFC 9559 §5.1.4.1.1 constrains TrackNumber only to be non-zero — nothing -requires `1..=N` in TrackEntry order. A file with a TrackType this reader -drops (18 = buttons) between two carried tracks makes the TrackNumber -space and the stream vector diverge. - -Regression (silent corruption): the reader computed the stream index as -`TrackNumber - 1`, so the audio blocks of TrackNumber 3 resolved to index -2 in a 2-stream title and were DISCARDED — a remux with no audio, reported -as success. -A legal SamplingFrequency below the lowest rate this enum maps must come -back as Unknown, not silently as 48 kHz. - -The ladder's final `else` was `SampleRate::S48`, so a 32000 Hz AC-3 or DTS -track — legal, and common in broadcast-sourced content — was recorded as -48 kHz and the wrong rate propagated into the reconstructed AudioStream. -The crate's canonical mapping, `SampleRate::from_hz`, returns Unknown for -32000; this ladder disagreed with it. - -## finish-produces-readable-mkv-test - -`finish()` is what turns a stream of frames into a FILE. It activates a -still-pending muxer (writing EBML header, Segment, Info, Tracks), then -finalizes it (Cues, SeekHead, the backpatched Segment size). A `finish` -that returned `Ok(())` without doing any of that leaves the caller with a -zero-byte or truncated `.mkv` and an exit code of 0 — a rip that reports -success and produced nothing. - -Proven by reading the output back through this crate's own MKV reader: -the frames must come out in order, with their real payloads, timestamps -and keyframe flags. - -## finish-refuses-zero-frame-title-test - -A title that produced NO frames must NOT finish successfully. `finish()` -activates the still-pending muxer (so the header/Tracks are written) and -then hands off to `MkvMuxer::finish`, whose zero-frame guard raises -`Error::MkvInvalid` (E6008) rather than emitting a structurally valid but -clusterless MKV. - -A `finish` that returned `Ok(())` would report a completed rip for a -title that muxed nothing — precisely the "empty title, exit code 0" -outcome the guard exists to prevent — and `error::is_skippable_title_stub` -would never get the code it classifies on. - -## headers-ready-and-untrusted-size-caps-test - -`headers_ready()` gates the CLI's wait-for-codec-private loop. For -Matroska it is unconditionally true because RFC 9559 §5.1 places the -Tracks element (carrying every CodecPrivate) in the Segment header, -ahead of the first Cluster — `MkvStream::open` has therefore already -parsed them by the time it returns. Returning `false` would hang the -mux forever on a source whose headers are, by construction, present. - -Pinned as an implication rather than a bare constant: readiness is -asserted TOGETHER with the codec private actually being retrievable, on -a freshly opened stream that has read no frame yet. -The untrusted-size caps ARE the OOM guard: every EBML element size is -checked against one before it is used to allocate. Only their existence -was pinned, never their magnitude — so a cap that collapsed to a few -kilobytes would still look guarded while rejecting ordinary discs, and one -that ballooned would allocate whatever a hostile container asks for. Both -ends need a number. - -## track-entry-metadata-round-trip-test - -Write a real three-track title through this crate's own muxer, then read -it back through this crate's own reader and check the TrackEntry metadata -survived. Language, track name, the forced flag, pixel height and channel -count each had a dedicated arm in `parse_track` and NONE of them was -asserted — every one could have been deleted and the suite stayed green -while a re-mux quietly lost the audio language, the subtitle forced flag, -the track labels, the resolution and the channel layout. - -## default-track-dedup-test - -FlagDefault says "play this track unless the viewer picks another". Only -ONE video and ONE audio track may carry it; `MkvTrack::video`/`audio` set -it from `!secondary` alone, so a disc with two ordinary video angles or -two ordinary audio tracks arrives here with the flag on all of them and -this de-duplication is the only thing that fixes it. It lives here and -nowhere else — the muxer just writes what it is handed — and it had no -test at all. - -## progressive-on-declared-interlaced-test - -A DVD whose IFO declares 480i/576i but whose pictures are CODED -progressive — film and animation on NTSC discs routinely are — must ship -`FlagInterlaced=progressive`, because that is what the bitstream says. - -`MkvTrack::video` sets `interlaced` from the DECLARED resolution, which -on a DVD is 480i essentially always. Shipping that unchecked marks -progressive content interlaced, and every player that honours the flag -then runs a deinterlacer over progressive frames — softening every frame -of an otherwise bit-exact remux. Measured against a real disc: `idet` -reports 100% progressive on titles this shipped as `FlagInterlaced=1`. - -The sibling test above pins the interlaced direction, so together they -constrain both: a measurement of TFF keeps the track interlaced, and a -measurement of progressive corrects it. - -## deferred-activation-end-to-end-test - -THE deferred-activation contract, end to end: the field order MEASURED -from the first coded picture has to reach the FILE. - -`apply_coding_to_track` was tested in isolation, which proves nothing -about whether the caller ever reaches it with a real measurement — and -the route there runs through the pending-buffer cap, the -"is this the video frame" test and the activation trigger, none of which -were observed from the outside. Any of them mis-set and the file ships -FieldOrder omitted: an interlaced DVD that players then deinterlace with -the fields in the wrong order (visible combing on motion). - -## video-master-walk-bounds-test - -The `Video` master's child walk must consume EXACTLY the bytes its -children declare. This crate's own writer happens to place every other -TrackEntry field ahead of `Video`, so an over-run there costs nothing — -but a foreign MKV (mkvmerge orders children differently) puts fields -after it, and an over-running walk then swallows them: the reader reports -language `und` for a track that declared one, on a file that is perfectly -well formed. - -## skip-bytes - -A skip that runs out of input before `n` bytes is a TRUNCATED element, and is -reported the same way `ebml::read_binary_val` reports a truncated body: as -`MkvSourceInvalid`. Discarding `io::copy`'s byte count instead made a skip -that hit EOF look like a success, so one corrupt size field mid-Clusters -drained the rest of the file, the next element header raised -`UnexpectedEof`, and `Stream::read` mapped that to `Ok(None)` — half the -title missing, `errors = 0`, `completed = true`. diff --git a/docs/mobj.md b/docs/mobj.md deleted file mode 100644 index 9d21bfc1..00000000 --- a/docs/mobj.md +++ /dev/null @@ -1,9 +0,0 @@ -# MOBJ navigation command layout - -`Cmd` is a decoded 12-byte HDMV navigation command. Field layout (big-endian): - -- byte 0 = `op_cnt(3) grp(2) sub_grp(3)` -- byte 1 = `imm_op1(1) imm_op2(1) branch_opt(4)` (low nibble) -- byte 2 low nibble = `cmp_opt` -- byte 3 low 5 bits = `set_opt` -- then `dst`(u32) and `src`(u32) diff --git a/docs/mp4-audio.md b/docs/mp4-audio.md deleted file mode 100644 index fdd0cfc7..00000000 --- a/docs/mp4-audio.md +++ /dev/null @@ -1,110 +0,0 @@ -# MP4 audio sample entries (`src/mux/mp4/audio.rs`) - -## Module scope - -The entry always describes the syntax actually found in the first audio -frame, not the codec the playlist claimed: a track the playlist calls DD+ -whose first syncframe is a legacy AC-3 one is declared `ac-3`/`dac3`. The fit -oracle (`audio_fits`) in this module, mirrored by the sink, is what decides -which codecs are muxable at all. - -## `EAC3_MIN_BSID` - -Lowest `bsid` that identifies an Annex-E (E-AC-3) bitstream: ETSI TS 102 366 -Annex E uses bsid 16, while 8 is AC-3 and 9/10 are the AC-3 alternate bit -stream syntax of Annex D. Both the parser and the sample-entry chooser read -this one constant so they cannot disagree about which syntax was found. - -## `BitReader::read` - -The accumulate step (`(v << 1) | bit`) is one instance of a pattern repeated -throughout this file — shift an accumulator left by exactly the width of the -next field, then OR in that field, mask-limited to the same width (the `push` -closures in `dac3_box`, `dec3_box` and `ddts_box`; the multi-byte bit-field -extractions in `parse_eac3` and `parse_dts`). Because the shift always -vacates precisely the bits the OR then fills, and never more, the two -operands never share a set bit — so `|` and `^` agree on every input, always. -Mutation testing flags each of these `|` sites as a surviving `|`→`^` mutant; -that is expected and is not a coverage gap. Don't write tests chasing it and -don't "fix" it by switching to `^` — either spelling is correct and equally -unenforceable by a test, so `|` stays because it is the conventional way to -write "set these bits" in a bitstream packer. - -## `dec3_box` - -`data_rate` states the bitstream's rate in kbit/s and must be non-zero; only -`parse_eac3` computes one, so this box is written only for a config that came -from an Annex-E syncframe (see `dolby_sample_entry`). - -## `DTS_AMODE_CH` - -DTS core base channel count per `AMODE` (all 16 defined values). Matches the -per-AMODE channel counts in ETSI TS 102 114 §5.3.1, the same table the -decodability gate in `dts.rs` (`DTS_AMODE_COUNT`) also uses, so a spec-legal -DTS-ES / 6.1 / 7.1 core (AMODE 13→7, 14/15→8) is declared with its true -channel count in the mp4 AudioSampleEntry / `ddts` box rather than a -truncated 6. - -## `DTS_AMODE_LAYOUT` - -`ddts` ChannelLayout speaker mask (ETSI TS 102 114 / DTS-in-ISOBMFF) per core -`AMODE`. Bit assignment: 0=C, 1=L/R, 2=Ls/Rs, 3=LFE, 4=Cs, 5=Lh/Rh, 6=Lsr/Rsr, -7=Ch, 8=Oh, 9=Lc/Rc, 10=Lw/Rw, 11=Lss/Rss, 12=LFE2, 13=Lhs/Rhs, 14=Chr, -15=Lhr/Rhr. Paired bits denote two speakers, single bits one. - -This must stay consistent with `DTS_AMODE_CH`: the `ddts` box declares both a -channel count and this mask, and a decoder may trust either — so a mask that -describes fewer speakers than the declared count makes the box -self-contradictory and provokes a downmix or an outright error. The previous -`_ => 0x0007` catch-all did exactly that for AMODE 6, 7, and 10 through 15. -AMODE 6 (`L + R + S`) is the reachable one: its `S` is a single -centre-surround, not the Ls/Rs pair, so it is 3 channels and `0x0012`, not 4 -and `0x0006`. `ddts_channel_layout_speaker_count_matches_declared_channels` -pins the invariant for all 16 values. - -The AMODE annotations on the table name the layout ETSI TS 102 114 §5.3.1 -gives for that AMODE and the mask that encodes it. Three of them used to be -rotated by one (AMODE 2 labelled "sum/difference", 3 "left/right total", 4 -plain "L/R") and AMODE 9 was labelled "5.1 core with LFE" although 0x0007 is -the 5.0 mask — LFE is bit 3, OR'd in separately by `dts_channel_layout`. Both -mislabels invited a "correction" to the values, which are right and are -pinned by `ddts_channel_layout_speaker_count_matches_declared_channels`. - -## `dts_channel_layout` - -`amode` must be 0..=15; `parse_dts` rejects the reserved 16..=63 before this -is reached, so there is no layout to invent for them. - -## `audio_fits` - -Fit oracle for an audio codec: does `mp4://` currently carry it? Covers the -Dolby family (AC-3 / E-AC-3) and DTS (core / DTS-HD HRA / DTS-HD MA — the -core is described, whole access units pass through). TrueHD, LPCM, AAC are -not yet mapped and are skipped with a loud report (never silently dropped). - -## Test fixtures - -`ac3_frame_distinct` (line ~905 in the test module): AC-3 syncframe with -every BSI field distinct: fscod=1 (44.1 kHz), frmsizecod=37 (bit_rate_code -18), bsid=8, bsmod=5, acmod=6 (2/2), lfeon=1 → 4.1 = 5 channels. -byte4 = fscod(2)=01 | frmsizecod(6)=100101 → 0x65; byte5 = bsid(5)=01000 | -bsmod(3)=101 → 0x45; byte6 = acmod(3)=110 | surmixlev(2)=00 | lfeon=1 | -pad 00 → 0xC4. acmod 6 has surround but no centre, so per §5.4.2 cmixlev is -absent and surmixlev is present: lfeon lands at bit 5 of byte 6. - -`eac3_frame_distinct`: Annex-E syncframe with every field distinct: -frmsiz=0x123 (292 words → 584 bytes), fscod=1 (44.1 kHz), numblkscod=0 -(1 block → 256 samples), acmod=5 (3/1), lfeon=1 → 5 channels, bsid=16. -byte2 = strmtyp 00 | substreamid 000 | frmsiz hi 001 → 0x01; byte3 = frmsiz -lo 0x23; byte4 = fscod 01 | numblkscod 00 | acmod 101 | lfeon 1 → 0x4B; -byte5 = bsid 10000 | dialnorm hi 000 → 0x80. - -`eac3_frame_fscod3`: Annex-E syncframe with fscod=3 — the reduced-sample-rate -path, which no other fixture reaches. fscod2=2 → 16 kHz, 6 blocks; acmod=2, -lfeon=0. byte4 = fscod 11 | fscod2 10 | acmod 010 | lfeon 0 = 0xE4. Exactly -6 bytes: also the minimum-length Annex-E header. - -`dts_frame_high_bits_set`: a DTS core whose split fields all have their high -parts set, so a lost high bit is visible: NBLKS bit 6 (byte 4 bit 0) and -FSIZE bits 13-12 (byte 5 bits 1-0). NBLKS=79 → 2560 samples; FSIZE=0x3000 → -core 12289. AMODE=9, SFREQ=13 (48 kHz), LFF=1 out of byte 10 = 0x0A. diff --git a/docs/mp4-mux.md b/docs/mp4-mux.md deleted file mode 100644 index ce572d34..00000000 --- a/docs/mp4-mux.md +++ /dev/null @@ -1,267 +0,0 @@ -# `src/mux/mp4/mod.rs` — progressive MP4 muxer notes - -Long-form rationale relocated from source comments (see `ci/comment-guard.py` -audience caps). Each section is pointed to from a short `//` comment at its -call site. - -## Module overview - -Progressive MP4 (ISO-BMFF) muxer for `mp4://`. Writes `ftyp` + `moov` + -`mdat` with faststart on by default: a `moov`-sized hole is reserved between -`ftyp` and `mdat` at the start (so sample offsets are fixed and never -rewritten), sample data streams into `mdat`, and at `finish()` the `moov` -index is written into the reserved hole with a trailing `free` box for the -slack. If the estimate is blown, it falls back to moov-at-end. Unlike the -fragmented `fmp4` sibling (DASH init+moof/mdat), this is a single -self-contained file — the shape people mean by "an mp4" — and moov-first -means it streams over HTTP without a pre-fetch. - -### Track model - -One video track (HEVC / H.264) plus every audio track whose codec has a -clean MP4 mapping (AC-3 → `ac-3`/`dac3`, E-AC-3 → `ec-3`/`dec3`, DTS/DTS-HD → -`dtsc`/`dtsh`/`ddts`). This is the fit oracle: a codec MP4 can't carry -(TrueHD, LPCM) or that has no sample entry here is excluded, never silently -dropped — `fit_report` lets the CLI enumerate exactly what was left out and -why. Video NALs pass through unchanged (the demux hands us length-prefixed -hvcC/avcC framing — already MP4's form). Decode timestamps are derived (the -pipeline carries presentation PTS only): video is constant-frame-rate on -disc, so a constant decode duration + signed `ctts` reproduces the B-frame -reorder exactly; audio has no reorder, so per-sample durations come straight -from the PTS deltas. - -Reference: ISO/IEC 14496-12 (ISO base media file format), 14496-15 -(avcC/hvcC). - -## `faststart_fits` - -Whether `gap` bytes of leftover reserved-hole slack (after `moov` is written -into it) can be closed: an exact fill (`0`) needs no `free` box at all, and -`8+` bytes is enough to hold one (an ISO-BMFF box header is 8 bytes: 4-byte -size + 4-byte type). A gap of 1–7 bytes cannot be expressed as any box, so -`Mp4Sink::finish` must fall back to moov-at-end instead of writing a `free` -box that lies about its own size. - -## `Mp4Sink::final_report` - -This exists because the plan is a PREDICTION. `finish()` drops an audio -track no frame of which yielded a parseable sample entry (it cannot be -described in `stsd`) and returns `Ok` so an export whose video is fine still -succeeds — but then the plan, which is the only structured report the crate -publishes, still named that stream as carried. A caller believing it, reports -a successful export of a file with no audio. Ask this after `finish()` -before telling anyone what was written. - -## `Mp4Sink::undelivered_streams` - -The driver surfaces this as `MuxOutcome::undelivered_streams` so the caller -learns programmatically that the file is missing a stream the pre-mux plan -promised, instead of only in a log line. - -## `STD_RATES` - -The order of this table is NOT significant: `detect_rate` picks the entry -nearest the measured rate, so a new rate may be appended anywhere without -shadowing an existing one. - -## `RATE_TOLERANCE_FPS` - -Half an fps separates every neighbouring pair in the table (23.976/24 are -0.024 apart, so both fall inside one another's window — which is exactly why -the match must be nearest-wins, not first-wins). - -Two mutants in `detect_rate`'s snapping loop — `d < RATE_TOLERANCE_FPS` and -the tie-break `d < best_d`, each flipped to `<=` — are not closed by any test -here, and are believed unreachable rather than merely untested: both require -an f64 distance computed as `(fps - rate).abs()` to land on EXACTLY `0.5`, or -on an exact tie between two candidate distances, where `fps` is `1e9 / -median` for an INTEGER nanosecond `median`. A brute-force search (median -from 1 to 1e8 ns, every `STD_RATES` entry) found no integer median whose -measured distance is bit-exact `0.5`, nor one producing an exact tie between -neighbouring entries: the target real number (e.g. `1e9 / 24.5`) is never -itself an integer, so no integer median's true quotient rounds to a double -that is bit-identical to the boundary. If a future change makes either -boundary reachable (e.g. by accepting a caller-supplied `median` directly -instead of deriving it from PTS deltas), revisit this. - -## `video_colr` - -The code points come from `crate::mux::mkv::cicp_for_video` — the single -resolver EVERY sink shares (measured bitstream CICP first, then the coarse -`ColorSpace` enum with the HDR-driven transfer override). This box must never -carry its own copy of that mapping: the copy that used to live here had -drifted to hardcode transfer 16 (SMPTE ST 2084 / PQ) for all BT.2020 — -tagging an HLG title, whose transfer is 18 (ARIB STD-B67), as PQ — and -transfer 6 (BT.601) for BT.470 System B/G, whose transfer is 5. Both -disagreed with the MKV sink and the FVI sidecar for the same disc. - -## Test: `a_video_track_with_no_resolved_resolution_is_an_error_not_a_zero_sized_track` - -ISO/IEC 14496-12 makes width and height mandatory in both `tkhd` (8.3.2) and -VisualSampleEntry (12.1.3), so unlike Matroska — which simply omits the -optional PixelWidth/PixelHeight elements — MP4 has nothing to leave out. -Writing zeros produces a structurally complete file that passes every -container check and that no player can render, with no error anywhere: a -wrong answer that looks like a successful rip. - -`Resolution::pixels()` returns `Option` for this reason. It used to return a -fabricated 1920x1080, then `(0, 0)`; the zero pair reads as a usable value, -so this sink stored it and serialised it, and the guard that two of the -three sinks have was never needed here and so was never written. - -## `find_child` (test helper) - -A minimal box walker duplicated here for tests — the reader's own -box-walking (`find_box`) lives in `read.rs` and is private to that module. - -## Test: `audio_track_with_no_parseable_sample_entry_is_dropped_not_emitted_empty` - -An audio track whose frames never yield a parseable sample entry must be -dropped from moov, NOT written as an stsd declaring entry_count=1 around an -empty entry — that is a structurally invalid mp4 returned as success. - -The video track must survive, and no audio frame may be lost from mdat on -the way: `write()` previously returned `Ok(())` without recording the -sample, so leading audio frames vanished silently. - -Mutation check: restore `write()`'s early `return Ok(())` and the -unparseable bytes never reach mdat; drop `finish()`'s describability retain -and moov gains a second trak carrying an empty sample entry. - -## Test: `dropped_audio_track_is_reported_not_just_logged` - -Dropping the undescribable audio track keeps the export succeeding (its -video is fine), but the crate must not then keep CLAIMING that stream: -`mp4_fit_report` — the only structured report — still lists it as included, -so a caller printing the plan reports a successful export of a file with no -audio at all. - -`final_report()` must therefore describe the FILE (the stream moved to -`skipped` with `UndescribableAudio`), and `undelivered_streams()` — which -the driver folds into `MuxOutcome::undelivered_streams` — must name it so -the loss is programmatic, not just a log line. - -Mutation check: stop recording the drop in `finish()` and the plan and the -file disagree again with nothing but a `tracing::warn` between them. - -## Test: `mvhd_next_track_id_exceeds_every_retained_track_id` - -`mvhd.next_track_id` must EXCEED every track_ID in the file (ISO/IEC -14496-12 §8.2.2). It was derived from the retained track COUNT, so a drop at -`finish()` made it collide with a live id: ids [1, 3] retained → count 2 → -next_track_id 3, which is track 3. A tool appending a track with that id -creates a duplicate. - -## Test: `pack_language_packs_three_lowercase_letters_into_15_bits` - -Every bit-twiddle in `pack_language`'s valid-input path (both shift amounts, -both `-0x60` subtractions on the first two letters, and the OR that -assembles them) pinned against hand-computed packed values, using letters -other than 'a' so a `-`↔`/` flip on the per-letter offset is visible: -`'a' - 0x60 == 1 == 'a' / 0x60`, so a fixture starting with 'a' cannot tell -subtraction from division apart on that letter. - -Three mutants of `pack_language` are NOT killed by this test, or by any -other — they are equivalent, proven by construction rather than merely -unobserved: - -1. `(b[0] - 0x60) as u16` → `(b[0] + 0x60) as u16` on the FIRST letter (the - one shifted `<< 10`). The two results differ by exactly `0x60 * 2 = 192 = - 3 * 64`, and multiplying by `1 << 10` then truncating to `u16` is - arithmetic mod `65536`; since `192 * 1024 = 196_608 = 3 * 65536`, the `+` - and `-` forms land on the identical `u16` for every possible byte, not - just the ones this fixture picks. (The same swap on the SECOND letter, - shifted only `<< 5`, is very much NOT equivalent — `192 * 32 = 6144` is - not a multiple of 65536 — which is why that one IS caught above and only - the first letter's `+` survives.) -2. Both `|` → `^` mutations that combine the three shifted fields. The three - components are each a lowercase letter minus `0x60`, i.e. in `1..=26`, - which fits in 5 bits (`0..=31`); shifted by `0`, `5` and `10` they occupy - disjoint bit ranges for every valid input, and OR and XOR agree exactly - when their operands share no set bit. - -## Test: `estimate_reserve_uses_the_streams_own_fps_not_the_24fps_fallback` - -`estimate_reserve`'s per-stream fps comes from `v.frame_rate.as_fraction()` -only when BOTH `n > 0 && d > 0`; otherwise it falls back to a flat 24.0. -`FrameRate::Unknown` is the only variant with `n == 0` (it reports `(0, -1)`), so it is the one real input that takes the fallback branch — every -other variant has `n, d > 0` and must use its OWN fps, not 24.0. - -A film-rate title (23.976 fps) is the fixture that can tell "used its own -fps" apart from "used the 24.0 fallback": at 24 fps flat those two numbers -coincide, so a guard broken by an operator flip (`&&`→`||`, `>`→`==`/`<`/ -`>=`) would be invisible on it. Duration 76_500 s is chosen so the two -answers land in different 4 MiB reserve grains, not just differ before -rounding. - -## Test: `estimate_reserve_unknown_frame_rate_falls_back_to_24fps_not_zero` - -The complementary case: `FrameRate::Unknown` (n=0) is the one real input -meant to take the fallback branch. If the guard's `n > 0` is weakened so -zero passes it (`==0`, `>=0`) or the `&&` becomes `||`, the code computes `0 -/ 1 = 0.0` fps instead of falling back to 24.0 — collapsing the estimate to -near-zero samples instead of a reasonable guess. - -## Test: `estimate_reserve_models_dts_at_512_samples_per_frame_not_1536` - -`estimate_reserve` models a DTS/DTS-HD-MA/DTS-HD-HR audio unit as 512 -samples (a DTS core AU is `(nblks+1)*32`), a THIRD of the 1536-sample -(E-)AC-3 default the `_` arm uses for everything else. Deleting the DTS -match arm silently reverts every DTS track to the AC-3 model, which -under-reserves a DTS-heavy title's sample table 3x and can push a real mux -onto the moov-at-end fallback. - -## Test: `tkhd_duration_is_seconds_times_movie_timescale_for_both_media_types` - -`build_video_trak_full` and `build_audio_trak_full` both compute -`tkhd.duration = secs * MOVIE_TIMESCALE` — a SEPARATE multiplication from -the media-timescale duration in `mdhd`, because `tkhd.duration` lives in the -movie's 90 kHz clock (ISO/IEC 14496-12 §8.3.2). Read it straight out of the -emitted `tkhd` bytes (offset 28 in a version-1 tkhd: version+flags (4) + -creation(8) + modification(8) + track_id(4) + reserved(4) = 28), so the -assertion is on what the file says, not a second copy of the formula. - -## Test: `audio_sample_durations_computes_exact_tick_deltas_and_repeats_the_last` - -`audio_sample_durations`'s per-sample ticks are `ns * ts / NS`, and the -inter-sample delta is `ticks(next) - ticks(prev)` (clamped at 0), with the -LAST duration repeated for the trailing sample. PTS deltas are chosen so -every tick value is an exact integer and the two windows differ (32 ms then -another 32 ms from a non-zero base), so a `-`↔`+` flip on the delta is -visible on the second window even though it is invisible on the first -(whose previous tick is 0). - -## Test: `audio_sample_durations_single_sample_uses_timescale_over_30_fallback` - -The single-sample fallback (`durs.last()` is `None`) pushes `timescale / -30`, guarded by `!samples.is_empty()`. A single sample exercises both: -`windows(2)` yields nothing, so the fallback branch is the only source of a -duration at all. - -## Test: `detect_rate_needs_at_least_two_samples_not_more` - -Fewer than 2 samples can't measure a delta at all: fixed 90 kHz/3003 -fallback (not 90 kHz/anything else, and not a panic on an empty median). -EXACTLY 2 samples is the boundary itself, not just "some samples fewer than -2" — a `<`→`==`/`<=` mutant on `samples.len() < 2` forces the fallback for 2 -samples too, even though they carry a perfectly good 25 fps delta. - -## Test: `detect_rate_filters_zero_deltas_not_just_negative_ones` - -Only POSITIVE deltas are considered (`filter(|&d| d > 0)`). A `>`→`>=` -mutant lets zero deltas (duplicate/out-of-order PTS) through, which shifts -which element `deltas[deltas.len() / 2]` lands on. Three duplicate -timestamps plus one real 25 fps delta make the shift land on the zero -itself: median becomes 0, `fps` becomes `NS / 0 = inf`, no `STD_RATES` entry -is within tolerance of infinity, and the fallback path's `median` of 0 -forces its duration floor of 1 — a completely different, clearly-wrong -answer from the correct (25, 1). - -## Test: `detect_rate_fallback_duration_is_median_times_90khz_over_ns` - -A rate with no nearby `STD_RATES` entry takes the fallback branch: -`timescale = 90_000`, `duration = (median * 90_000) / NS`. 5 fps (200 -ms/frame) is chosen so `median * 90_000` is an exact multiple of `NS`, -giving a clean expected duration that a `*`↔`+`/`/` flip on either operator, -or `/`↔`%`, cannot coincidentally reproduce. diff --git a/docs/mp4-read.md b/docs/mp4-read.md deleted file mode 100644 index b20c4880..00000000 --- a/docs/mp4-read.md +++ /dev/null @@ -1,499 +0,0 @@ -# mp4 read (`src/mux/mp4/read.rs`) — internal notes - -## `MAX_TRACKS` - -Track count is otherwise unbounded (a crafted `moov` can pack tens of -thousands of `trak` boxes), and the per-track PID is `0x1011 + track_idx`, -which overflows `u16` past ~61k tracks. Real titles have well under a -hundred tracks. - -## `MAX_SAMPLE_COUNT` - -MP4 sample-table fields (`stsz` sample_count, `stts`/`ctts` run-lengths) are -untrusted 32-bit values; a crafted box can declare billions of entries in a -few bytes. Real titles stay far under this (a 10 h/60 fps track is ~2M -samples), so clamping to it caps a hostile file's allocation without -truncating any legitimate track. - -## `MIN_FILE_BYTES_PER_SAMPLE` - -Only `vide` and `soun` tracks are indexed here, and no real coded video or -audio access unit is anywhere near this small: the shortest legal AC-3 -frame is 128 bytes, an AAC frame is hundreds, and a video sample carries at -least a NAL header plus slice data. A 2-hour title runs to thousands of -file bytes per sample, so this cannot truncate a genuine track — it only -stops a crafted sample table from claiming more samples than the file -could possibly hold. - -## Test rationale (`mod tests`) - -### `FakeBigReader` - -A `Read + Seek` backed by a small crafted prefix followed by an endless run -of zeros, reporting `len` bytes total on `seek(End)`. Lets a test exercise -a hundred-MiB-to-multi-GiB boundary (`MAX_ALLOC_BYTES`, a sparse-file-inflated -`file_len`) without a real backing allocation of that size for the SOURCE -side — only the destination buffer the code under test allocates is real, -which is the point of the test. - -### `parse_stss_reads_two_distinct_entries_from_their_own_offsets` - -`parse_stss` had no direct positive-path test anywhere in this file — only -its short-buffer safety was pinned. Two real, distinct entries in a buffer -sized to exactly fit them (`b.len() == 16`, so the second entry's -`o + 4 == b.len()` exactly) separates every arithmetic mutant at once: an -`o = 8 + i*4` → `8 - i*4` slip reads the `count` field instead of the -second entry; an `i*4` → `i/4` slip re-reads the FIRST entry for the -second (integer division collapses `i=1` back to the same offset as -`i=0`); and an `o + 4 > b.len()` → `o + 4 < b.len()` slip breaks out on -the very first entry, because there IS more room after it (`12 < 16`) — -leaving the set empty instead of holding both. - -### `parse_stss_count_lie_is_bounded_by_the_box_not_trusted` - -A declared `count` larger than the box can hold (a lie, or a truncated -box) must be bounded by the per-entry guard, not by trusting the count — -the SAME contract `parse_stco`/`parse_stsc`/`parse_stts` pin with their -own "count lie" tests. Bounded HERE means the loop must stop, not read -past the end: an `o + 4 > b.len()` → `o - 4 > b.len()` mutant almost -never fires (`o - 4` stays small for every realistic `o`), so it lets -the loop walk straight past a truncated box into an out-of-bounds `be32` -read. - -### `sample_offsets_advances_through_distinct_sizes_within_one_chunk` - -`sidx` must advance by exactly one PER SAMPLE PLACED, so the third (and -every later) sample in a chunk picks up its own size, not the first -sample's reused over and over. With only two samples in a chunk this bug -is invisible — the second sample's pushed offset was already computed -from `sizes[0]` before `sidx` had a chance to matter — so this test needs -a chunk with THREE distinctly-sized samples to expose it. - -### `stream_read_allows_a_sample_of_exactly_max_alloc_bytes` - -`Stream::read`'s own `s.size as u64 > MAX_ALLOC_BYTES` cap — a SEPARATE -call site from `read_moov`'s (see -`read_moov_allows_a_payload_of_exactly_max_alloc_bytes` / the module doc's -note on the same policy hardened at only one of two sites elsewhere in -this crate). A `>`↔`>=` mutant here would reject a sample of exactly the -cap, which must be allowed. Builds an `Mp4Reader` directly (its fields are -private but visible to this module's tests) over a `FakeBigReader` so the -256 MiB sample doesn't need a real backing file — only the destination -buffer `read()` allocates is real. - -### `stream_read_rejects_a_sample_one_byte_over_max_alloc_bytes` - -The same cap's other edge: one byte OVER it must still be rejected. A -`>`↔`==` mutant would only catch a sample of EXACTLY the cap and let -anything larger through — the worse direction to get wrong, since it -turns the cap into a no-op for every real oversized/hostile sample. - -### `stts_expands_runs_to_per_sample_deltas_in_order` - -`stts` run-length expansion — ISO/IEC 14496-12 §8.6.1.2. The box stores -`(sample_count, sample_delta)` runs; the reader must expand them back to -one delta PER SAMPLE, in order, or every sample after the first run lands -on the wrong decode time. - -(This test used to be called `stts_and_ctts_expand` while touching no -`ctts` box at all. The composition-offset half now lives in -`ctts_build_and_parse_are_exact_inverses_over_signed_offsets` and -`b_frame_presentation_order_survives_the_mp4_round_trip`.) - -### `declared_track_duration_equals_frame_count_times_frame_duration` - -A track's declared duration must be its real length. `mdhd.duration` is in -the MEDIA timescale and `mvhd.duration` in the movie timescale -(ISO/IEC 14496-12 §8.2.2, §8.4.2); a player uses them to draw the seek bar -and to decide when the title ends, so a zeroed or constant duration makes -a correct file unseekable and apparently empty. - -### `moov_tree_carries_the_mandatory_track_header_and_media_boxes` - -The `moov` tree must carry the boxes ISO/IEC 14496-12 makes mandatory for -a playable track, with the field values the spec fixes. These live in -this module rather than the writer's because the box-walking helpers -(`find_box`) are here — asserting through them means the test reads the -file the way the demuxer does, instead of re-deriving the layout. - -Nothing in the demux path needs `tkhd`/`vmhd`/`smhd`/`dinf`, so an empty -one of any of them round-trips through this crate unnoticed while making -the file unplayable elsewhere. - -### `table_parsers_reject_every_length_up_to_the_header_size` - -Every one of these five table parsers opens with `if b.len() < 8 { return -<empty> }` before reading `count = be32(b, 4)`, which needs `b.len() >= 8`. -No existing fixture ever called any of them with a buffer shorter than 8 -bytes — every test builds a complete box — so a `<`→`==` mutant of that -guard (which only early-returns at EXACTLY `b.len() == 8`, letting every -length below it fall through to the out-of-bounds `be32` read) had -nothing to fail against. Pins the entire boundary at once, every length -from 0 through 8, for all five. - -### `parse_stco_co64_reads_each_byte_from_its_own_offset` - -`co64`'s 8-byte offsets are decoded byte-by-byte -(`u64::from_be_bytes([b[o], b[o+1], ..., b[o+7]])`) — a SEPARATE code path -from the 32-bit `stco` case, which goes through `be32` instead and was -never itself built with a co64 fixture anywhere in this file. Every byte -here is distinct, so an index slip (off by one, negated, or scaled) that -reads the wrong byte cannot agree by coincidence. - -### `parse_stsd_rejects_every_length_below_the_header_size` - -Every buffer shorter than the 8-byte version+flags+entry_count header -must return `None` — never fall through to `&b[8..]`, which would slice -past the end and panic. `b.len() == 8` is the boundary itself (see the -equivalence note on `parse_stsd`'s guard); everything strictly below it -must be rejected by THIS check, not rely on a later one that isn't -reached yet. - -### `parse_esds_asc_boundary_checks_are_independent` - -The final guard is `asc_len == 0 || end > b.len()` — two independent -rejection reasons, not one condition that needs both. Pins each half: - * `asc_len == 0` must be rejected even though `end == pos` is trivially - in bounds (an `||`→`&&` mutant would let a useless zero-length ASC - through as `Some(vec![])`); - * `end > b.len()` means TRUNCATED, not "anything before the buffer's - end" — trailing bytes after a complete ASC (`end < b.len()`) must - still succeed (a `>`→`<` mutant rejects exactly this, the common case - of an esds embedded inside a larger box with more child boxes or - padding after it). - -### `read_moov_exactly_header_sized_is_a_valid_empty_box` - -`box_size < header_len` rejects a box that cannot even hold its own -header. A box whose size is EXACTLY `header_len` (8, no 64-bit largesize) -is the boundary itself: legal (an empty box), so an empty `moov` — size -8, zero payload bytes — must parse to `Ok(vec![])`, not be rejected by a -`<`→`<=`/`==` mutant that also rejects the boundary. - -### `read_moov_rejects_a_box_that_overruns_the_file_via_the_or_not_and_guard` - -The forward-progress / EOF guard is `box_size < header_len || -pos.checked_add(box_size).is_none_or(|end| end > file_end)` — an `||`↔`&&` -mutant only shows up on an input where EXACTLY ONE side is true. A box -that claims to run 992 bytes past a file that ends right after its header -satisfies only the second clause (`box_size >= header_len`, so the first -is false); under `&&` the guard would not fire, and `read_moov` would -instead fail later inside `read_exact` with a plain `UnexpectedEof` — a -different, uncoded `io::Error` rather than this crate's `Mp4Invalid` -(`ErrorKind::InvalidData`), which the guard exists to produce uniformly -for every malformed box. - -### `read_moov_allows_a_payload_of_exactly_max_alloc_bytes` - -`payload_len > MAX_ALLOC_BYTES` is a SEPARATE, sparse-file-proof cap from -the EOF check above (see -`read_moov_over_cap_rejected_despite_inflated_file_len`). A `>`→`>=` -mutant would reject a `moov` whose payload is EXACTLY `MAX_ALLOC_BYTES`, -which must be allowed. - -### `edit_list_media_time_shifts_the_presentation_timeline` - -Regression (silent A/V desync): the sample timeline was built purely from -stts/ctts starting at tick 0 and no `edts`/`elst` was ever parsed, so the -presentation timeline an edit list defines was discarded. A non-empty -edit with `media_time = 1024` — the standard way encoder delay is -expressed — must move the track's presentation, not be ignored. - -### `parse_elst_v1_entry_bytes_come_from_their_own_offsets` - -Every byte of a version-1 entry's `segment_duration` (8 bytes) and -`media_time` (8 bytes) at its own offset — not a neighbour's. The -existing fixtures elsewhere use `5_000u64` and `-1i64`, which are almost -all zero/`0xFF` bytes, so an index slip that reads an adjacent byte (or a -header byte outside the entry) often reads the SAME value and the test -cannot tell. Every byte here is distinct and nonzero, and the header -bytes are zero, so any wrong index — off by one, negated, or scaled — -pulls in a value that cannot match by coincidence. - -### `elst_offset_ticks_stays_safe_at_zero_empty_ticks_even_with_a_zero_movie_timescale` - -`Some(mts) if empty_movie_ticks > 0` and the `None` branch's `if -empty_movie_ticks > 0` are both guards around a `tracing::warn!`/delay -computation that, at `empty_movie_ticks == 0`, must produce the exact -same answer as skipping it (0 ticks of delay — there is no empty edit to -convert). The one case where the two are NOT interchangeable is -`movie_timescale == Some(0)`: skipping the arithmetic returns 0, but -entering it divides by that zero timescale and panics. Real callers -never pass this — `from_reader` filters `mvhd`'s timescale through -`.filter(|&t| t != 0)` before it ever reaches this function (see -`mdhd_timescale_zero_does_not_divide_by_zero` for the sibling guard on -the media timescale) — but the function itself has to stay panic-free at -its own boundary, independent of what its one caller happens to do -today, because `libfreemkv` runs inside the long-lived `autorip` service -and a panic there is downtime. - -### `audio_trak_hostile_count` - -A crafted file with a *fixed-size* `stsz` (sample_size != 0) claiming -count = 0xFFFFFFFF must not inflate the sample index past the file's own -byte length. `from_reader` sets `sample_budget = -MAX_SAMPLE_COUNT.min(file_len)`, so a few-hundred-byte file bounds the -`Vec<SampleRef>` to a few hundred — NOT the 16M `MAX_SAMPLE_COUNT` -ceiling. Mutation check: revert the budget to a bare `MAX_SAMPLE_COUNT` -and this file yields ~16M samples, failing the `<= file_len` (and `< -MAX_SAMPLE_COUNT`) assertions below. Built by a minimal-but-complete -audio `trak` whose fixed-size `stsz` LIES about its sample count -(`u32::MAX`), with an stsc/stts wide enough to place whatever count -survives the budget. - -### `sample_index_ram_is_bounded_by_a_multiple_of_the_file` - -The RAM amplification the byte-per-sample budget actually bounds: a -small crafted file whose `stsz` claims `u32::MAX` samples must not force -an eager multi-hundred-MB index. At ~60 bytes of RAM per indexed sample, -a budget of one sample per file byte gave ~60x the input size; the -assertion pins the amplification factor rather than a raw count, so it -fails if the budget ever goes back to counting samples per byte. - -### `audio_trak_missing` - -Build an audio `trak` identical to `audio_trak(48_000)` but with the -named stbl child box omitted. Used to reach the untrusted-input guards -that drop a track whose `stsz` says samples exist yet whose `stco`/`co64` -(chunk offsets) or `stsc` (sample-to-chunk map) is missing — without such -a table every sample offset would resolve near file byte 0. - -### `missing_stco_drops_track_all_dropped_is_invalid` - -A track with samples (`stsz`) but no chunk-offset table (`stco`/`co64`) -must be DROPPED, not indexed with offsets that resolve near file byte 0. -With it the only track, the whole file fails `Mp4Invalid`. Mutation -check: delete the `if chunk_offsets.is_empty() { continue; }` guard and -`from_reader` returns `Ok` (garbage samples), flipping this to FAIL. - -### `missing_stsc_drops_track_all_dropped_is_invalid` - -A track with samples (`stsz`) and chunk offsets (`stco`) but no -sample-to-chunk map (`stsc`) must be DROPPED — without `stsc` the -samples cannot be placed against the chunk offsets and would pack from -byte 0. Mutation check: delete the `if stsc.is_empty() { continue; }` -guard and `from_reader` returns `Ok`, flipping this to FAIL. - -### `missing_stts_drops_track_all_dropped_is_invalid` - -A track with samples (`stsz`) but no time-to-sample table (`stts`, -mandatory per ISO/IEC 14496-12 §8.6.1) must be DROPPED — without it every -sample takes dur=0, collapsing the whole track onto one instant -(all-zero timestamps). With it the only track, the file fails -`Mp4Invalid`. Mutation check: delete the `if durations.is_empty() { -continue; }` guard and `from_reader` returns `Ok` (all-zero-timestamp -samples), flipping this to FAIL. - -### `stsc_placing_fewer_samples_than_stsz_drops_the_track` - -An `stsc` that PASSES the non-empty guard but places fewer samples than -`stsz` declares must drop the track. `sample_offsets` used to pack the -unplaced tail after the last known offset, inventing a position, so -those frames were read from arbitrary file bytes — the same "emit -garbage" outcome the stco/stsc presence guards exist to refuse. - -Mutation check: restore the trailing `while offsets.len() < sizes.len()` -pack-after-last loop and this file indexes 3 samples instead of erroring. - -### `short_stts_drops_the_track_like_an_absent_one` - -A SHORT `stts` — present and non-empty, but covering fewer samples than -`stsz` declares — must drop the track just like an absent one. The tail -samples took `dur = 0`, collapsing the whole tail onto a single -timestamp, which is the exact degenerate timing the absent-stts guard -refuses. - -Mutation check: weaken the guard back to `durations.is_empty()` and this -file indexes 3 samples whose last two share one timestamp. - -### `mdhd_language_boundary_rejects_short_not_merely_non_exact` - -The length guard is `b.len() < off + 2` — reject too SHORT, not "not -exactly `off + 2`". Pins both edges of that boundary for the version-0 -offset (`off == 20`): - * `b.len() == off` (no room for the packed field at all) must return - `None`, not read two bytes past the end; - * `b.len() == off + 3` (one byte MORE than the minimum) must still - succeed — a `<`→`>` mutant of the guard would reject this case, since - `off + 3 > off + 2`. - -### `parse_stsd_takes_height_from_its_own_field_not_the_width_beside_it` - -`height` is the SECOND of the two 16-bit dimensions in a -VisualSampleEntry, at byte 26 — width sits at 24. Reading the wrong one -is silent: the value is still a plausible dimension, so the track's -seeded resolution simply comes out wrong (1920 read as a height would -classify a 1080p title as UHD). The two are deliberately different here; -a fixture with width == height would pass under either offset. - -### `parse_stsd_reads_channelcount_from_its_own_field_and_defaults_a_short_entry` - -`channelcount` is at byte 16 of an AudioSampleEntry, after the 8 reserved -bytes that follow `data_reference_index`. An offset slip reads a reserved -field, and reserved fields are conventionally zero — which would make -every audio track come out as 0 channels rather than fail. The -short-entry fallback of 2 is pinned in the same test: an entry with no -room for the fixed part still has to name SOME channel count, and 0 is -not a usable one. - -### `parse_stsd_extracts_aac_codec_private_only_when_the_body_is_long_enough` - -`mp4a`'s codec_private (the AudioSpecificConfig inside a child `esds` -box) is only read when the entry is `Codec::Aac` AND its body is at -least the 28-byte fixed AudioSampleEntry part — `find_box(&body[28..], -...)` would slice past the end otherwise. No existing test built an -actual `mp4a` entry, so neither half of that guard (`&&`, or the `>= 28` -on its right) was constrained: an `&&`→`||` mutant reaches the same -`&body[28..]` on a codec-only match, and a `>=`→`<` mutant loses a real -title's AAC CodecPrivate outright by only running on entries too short -to have one. - -### `parse_stsd_recognises_every_audio_fourcc` - -Every recognised audio fourcc must map to its own `Codec`, checked one by -one. `ec-3`/`dtsc`/`dtse`/`dtsh`/`dtsl` had no coverage at all before -this test — a deleted match arm for any of them falls through to the -catch-all `_ => return None`, silently dropping every E-AC-3 or DTS -variant track from the title instead of remuxing it. - -### `read_descriptor_len_is_a_four_byte_base_128_varint` - -A descriptor length is a base-128 varint: 7 bits per byte, continued -while the top bit is set, to a maximum of FOUR bytes. Every existing -esds fixture uses a single-byte length, so the continuation path is -unconstrained by them — yet a multi-byte length is exactly what an -`esds` carrying a long AudioSpecificConfig (or one written by a tool -that always pads to 4 bytes, which is common) uses. - -### `parse_esds_asc_steps_over_every_optional_es_descriptor_field` - -The optional `ES_Descriptor` fields (ISO/IEC 14496-1 §7.2.6.5) are -selected by three flag bits, and each one that is set inserts bytes -before the `DecoderConfigDescriptor`. Skipping them wrongly does not -corrupt anything — the tag check fails and `parse_esds_asc` returns -`None`, so the AAC track simply loses its CodecPrivate and the remux -emits AAC no decoder can initialise. - -The existing fixture has flags = 0, so all three skips are -unconstrained. This one sets all three at once and still has to reach -the same ASC. - -### `find_boxes_capped_refuses_a_box_smaller_than_its_own_header` - -A box header is 8 bytes, so a declared `size` below 8 cannot describe a -box — and taking it at face value slices `payload[pos + 8 .. pos + -size]` with the start past the end, which panics. A crafted `moov` is -untrusted input read straight off a user's file. - -### `find_boxes_capped_decodes_the_size_field_from_its_own_bytes` - -The declared box size is decoded from four specific bytes -(`payload[pos..pos+4]`, big-endian). A size deliberately chosen so byte -1 differs from byte 0 catches an index slip that rereads byte 0 (or any -other wrong offset) instead of the byte the field actually occupies — -with a small size every byte but the last is zero, so such a slip would -go unnoticed. - -### `sample_offsets_clamps_an_stsc_run_that_outruns_the_chunk_table` - -An `stsc` entry names a `first_chunk` that may exceed the chunk count -the `stco` actually declares (a truncated or crafted table). The run it -would fill has to be clamped to the chunks that exist — indexing `spc` -past its length is a panic on a file the user merely opened. - -The clamp is only reachable through a NON-final entry: the final entry's -end is `n_chunks` by construction, so a fixture whose only over-range -entry is last never reaches the line. - -### `elst_offset_ticks_honours_the_first_media_edit_not_the_last` - -ISO/IEC 14496-12 §8.6.6: an edit list may hold several media edits. This -frame model can only express a constant shift, so it honours the -LEADING one and logs the rest — taking the last instead would shift the -whole track by a trim that belongs to a later segment, i.e. silent A/V -desync of exactly the size of the difference. - -### `mvhd_timescale_version_1_reads_past_the_64_bit_times` - -A version-1 `mvhd` carries 64-bit creation/modification times, so its -`timescale` sits at byte 20 rather than 12 (ISO/IEC 14496-12 §8.2.2). -Every existing fixture is version 0, so the version-1 offset is -unconstrained by them — and it is not a hypothetical: writers emit -version 1 whenever the movie duration does not fit 32 bits. - -A wrong offset reads part of the 64-bit modification time, which is a -large arbitrary number — and the movie timescale is the denominator -that converts an empty edit's delay into media ticks, so the A/V offset -it produces is arbitrary too. - -### `parse_elst_entry_count_is_capped` - -Only the leading empty edits and the FIRST media edit shape the offset, -so `MAX_ELST_ENTRIES` bounds what a crafted `elst` can allocate. Without -it a box declaring millions of entries — and carrying the bytes for -them, inside a `moov` already capped at 256 MiB — expands to a Vec of -20-byte tuples for no benefit at all. - -### `non_av_handler_track_is_dropped_not_folded_into_audio` - -A `hdlr` of anything other than `vide`/`soun` (e.g. a hint or subtitle -track) must be dropped, not folded into the audio branch. Before this -guard existed as a literal `&h == b"soun"` comparison, weakening it to -"anything that isn't vide" would silently turn a hint track's `stsd` -entry into a fabricated `AudioStream` — with whatever codec its sample -entry happened to name. - -### `per_track_pid_arithmetic_is_exact_past_the_first_track` - -The per-track PID is `0x1011 + track_idx` for video and `0x1100 + -track_idx` for audio. At `track_idx == 0` a `+`↔`-`/`*` mutant of either -is invisible (`0x1011 + 0 == 0x1011 - 0 == 0x1011 * 0`… no — `* 0` is 0, -already different — but `+`↔`-` specifically needs `track_idx != 0` to -separate). Four tracks (two video, two audio) push `track_idx` to 1 and -3 respectively, so every one of `+`→`*`, `+`→`-` is forced to disagree -with the correct PID on the second track of its kind. - -### `track_idx_advances_past_a_sample_less_track` - -A track whose `stsz` is absent (`n == 0`) still occupies a `track_idx` -slot and must still advance the counter for the NEXT track — otherwise -two tracks collide on the same PID, or (worse, if the increment -direction is reversed) `track_idx` underflows a `usize` and panics on -the very first sample-less track. - -### `sample_budget_is_shared_and_exhausted_across_tracks` - -The global `sample_budget` is DECREMENTED by each track's real sample -count, and it is SHARED across tracks — a track late in the file gets -only what earlier tracks left behind. Build two tracks that both LIE -about their sample count (`stsz` fixed-size, count = `u32::MAX`, as in -`audio_trak_hostile_count`): the file is tiny, so the first track's -clamped count exactly exhausts the whole initial budget, and the second -track — clamped against what's left — must come back with ZERO samples. -An `+=`/`/=` mutant of the decrement either grows the budget or leaves -it nonzero, letting the second track claim samples it must not have. - -## `elst_offset_ticks` - -Presentation-time offset an edit list imposes on a track's samples, in the -track's MEDIA timescale ticks (ISO/IEC 14496-12 §8.6.5-§8.6.6). - -Two constructs cover essentially every real edit list, and both reduce to a -constant shift of the whole track: - * an EMPTY edit (`media_time == -1`) before the media edit, whose - `segment_duration` — in MOVIE timescale ticks — delays presentation; - * a non-empty edit whose `media_time` trims that much media off the front. - -So the offset is `(sum of leading empty segment_durations) - media_time`. -A list with several non-empty edits, or a non-empty edit at a rate other -than 1, describes a timeline this frame model cannot express (it would -need samples dropped, reordered or repeated); the leading edit is still -honoured, and the part that is not is LOGGED rather than passed off as a -faithful copy. - -## `MAX_ALLOC_BYTES` - -The EOF check alone is not enough: `file_len` is cheaply inflatable with a -sparse file (`truncate -s 8G`), so a crafted stsz size or moov box size -just under an 8 GiB apparent length would otherwise force a multi-GiB -allocation. No real sample or moov approaches this. diff --git a/docs/mpeg2.md b/docs/mpeg2.md deleted file mode 100644 index c0dee962..00000000 --- a/docs/mpeg2.md +++ /dev/null @@ -1,101 +0,0 @@ -# MPEG-2 video parser notes - -## Module overview - -**One PES is NOT one frame.** On a DVD the video elementary stream is sliced -into ~2 KB Program-Stream PES packets (one per 2048-byte pack), so a single -coded picture (~10-100 KB) spans many PES packets and only the first carries -a PTS. Emitting one MKV block per PES would write frame *fragments* — the -decoder then sees truncated pictures (`ac-tex damaged`) and picture-coding -extensions detached from their picture header (`ignoring pic cod ext`). So -this parser buffers ES bytes across PES packets and emits exactly one Frame -per coded picture. (Blu-ray aligns one access unit per PES and would not need -this, but DVD MPEG-2 PS does.) - -Access-unit model (ISO/IEC 13818-2): an AU is an optional sequence header + -optional GOP header + one picture header + its coding extension + slices. A -new AU begins at the next picture / sequence / GOP start code *once the -current AU already contains a picture* — leading sequence/GOP headers attach -to the picture that follows them. - -Start codes: -- Picture header: 00 00 01 00 -- Slice: 00 00 01 01 .. AF -- Sequence header: 00 00 01 B3 -- Extension (seq/pic):00 00 01 B5 -- GOP header: 00 00 01 B8 - -## `MAX_PENDING_BYTES` - -`MAX_PENDING_FRAMES` alone bounds the *count* of frames held awaiting the -first PES PTS anchor, but 600 full HD/UHD intra pictures can be ~1 GiB. This -mirrors the AC-3/DTS/PGS byte caps: once the held data exceeds this, release -on the 0 base instead of accumulating further. 8 MiB is roughly a few large -I-frames, far more than the ~15 frames a well-formed DVD buffers before its -first PTS. - -## `Mpeg2Parser::process_au` - -Processes one reassembled access unit (from -[`AuAssembler`](crate::mux::au_assembly::AuAssembler)): decodes its -per-picture coding info, captures a new sequence header, and buffers the -picture into the current GOP for display-order timestamping. The AU's -timing / source / discontinuity were already attributed by the assembler. - -## `Mpeg2Parser::flush_gop` - -Emits the buffered GOP. Each frame's PTS is the display-order prefix-sum of -field durations from the timeline origin; its block duration is its own -`nb_fields × field_period`. Frames are emitted in DECODE (buffer) order — -B-frames keep their position with a correctly LOWER PTS, never reordered -(reordering emitted blocks is what corrupts the picture). The origin is -(re-)locked to the GOP's PES PTS; because that is a *presentation* -timestamp, backing out the carrying frame's display-field offset keeps the -timeline continuous and monotonic across GOP boundaries. - -## `extract_seq_header` - -Extracts the sequence header (+ any B5 extensions / user-data, up to the -first GOP or picture start code) from a fully-assembled access unit — -exactly the extradata an MPEG-2 decoder expects as codecPrivate. Returns -None if the access unit carries no sequence header. A NEW header replaces -the stored one (title boundary / channel change), so its extension is -always re-captured. - -## `picture_coding_flags` - -Extracts the picture-coding-extension field/pulldown flags -`(top_field_first, repeat_first_field, progressive_frame, frame_picture)` -from a coded access unit (`00 00 01 B5`, ext-id `1000`), per ISO/IEC -13818-2 §6.3.10. The four bits feed the codec-agnostic `PictureInfo`. -Returns a progressive whole-frame default `(false, false, true, true)` when -no picture coding extension is present (MPEG-1 / no interlace signalling), -so the muxer omits `FieldOrder` rather than asserting a guess. - -## `picture_nb_fields` - -Returns the number of field-display periods a coded picture occupies, from -its picture coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC -13818-2 §6.3.10 (`nb_fields = repeat_pict + 2`, the field count the spec's -repeat rules yield). This is what times soft-telecined (2:3 pulldown) DVD -video correctly: a `repeat_first_field` frame occupies 3 fields, a normal -frame 2, so honoring it spreads the ~23.976 coded frames across the 29.97 -display span with no gap (the "play, pause, play" judder). `progressive_sequence` -comes from the sequence extension. Returns 2 (a normal frame) when no -picture coding extension is present. - -## `parse_progressive_sequence` - -Reads `progressive_sequence` from a captured sequence header's sequence -extension (`00 00 01 B5`, ext-id `0001`). False when absent (MPEG-1 / no -extension) — the interlaced default. Bit layout after the start code: -ext-id(4) profile_and_level(8) **progressive_sequence(1)** … so it is bit 3 -of the second extension byte (`hdr[q+5]`). - -## Test: `discontinuity_offset_mark_stamps_post_gap_picture_not_previous` - -B1 hole-2 regression: MPEG-2 buffers a GOP and emits asynchronously, so a -concealed gap must be associated by OFFSET (like PTS), landing on the -picture whose own bytes begin after the gap — NOT the previous picture -that completes when the discontinuity PES arrives. pic1 (I) is pre-gap; -pic2 (P), carried by a `discontinuity` PES, is the first post-gap AU. diff --git a/docs/mpegaudio.md b/docs/mpegaudio.md deleted file mode 100644 index caa07431..00000000 --- a/docs/mpegaudio.md +++ /dev/null @@ -1,17 +0,0 @@ -# MPEG audio framing and validation - -MPEG-1/2/2.5 audio frames are assembled across PES boundaries and split when -one PES contains multiple frames. Header version, layer, bitrate, sample rate -and padding determine the frame size and sample duration. Timestamp anchors -and source provenance survive fragmentation. Invalid reserved header fields -are rejected; audio payload CRCs are not checked. - -Free-format bitrate index zero remains a legal passthrough case at PES -granularity because its header does not signal a frame length. Before sync is -seen, nonsync payloads retain passthrough behavior; after sync, later PES -payloads are continuations. Partial EOF frames are discarded, not manufactured. -Buffering is capped at 1 MiB. - -Rust tests cover every split point, multiple frames per PES, version/layer -frame-size tables, timestamps and invalid headers. The FFmpeg QA gate compares -decoded PCM after deliberately fragmented MP2 input. diff --git a/docs/mpls-universal.md b/docs/mpls-universal.md deleted file mode 100644 index d06b3d6b..00000000 --- a/docs/mpls-universal.md +++ /dev/null @@ -1,95 +0,0 @@ -# `src/labels/mpls_universal.rs` - -Universal MPLS-based stream labels — the confidence-tier "floor" for the -`labels` registry. - -## Why this module exists - -Unlike the framework-specific parsers in `src/labels/` (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. - -## `build_labels` - -Converts every stream entry across `playlists` into one `StreamLabel` per -physical stream. Factored out of `parse` so unit tests can drive the actual -conversion logic (stream-type mapping, identity, slot numbering) directly from -already-parsed `crate::mpls::Playlist` values, without needing a synthetic -on-disc UDF image. - -Identity is `(clip, PID)` — what the STN entry states — and it is both the -dedup key and the label's `StreamId`. A stream twenty playlists list is one -label; two clips that both open their first audio at 0x1100 are two. This -replaced a disc-global dense counter that numbered surviving entries 1, 2, 3, -… in playlist-directory order: that number was not an STN slot in anything, -but it was handed to a binder that reads `stream_number` as one. - -## `label_type_for` - -Which per-type numbering list an STN entry belongs to, or `None` when it is -not a labellable stream at all. - -This MUST agree with the stream list `disc::bluray` builds from the same -entries, because that list is what `labels::apply_labels` counts against when -it binds `stream_number`. The two counters run over the same STN entries in -the same order, so any entry one side keeps and the other drops — or files -under a different type — shifts every later label of that type onto the wrong -stream. Three rules, all mirroring `disc::bluray`: - -* `coding_type == 0` is the STN table's empty/padding slot. Not a stream on - either side. -* a PG coding_type in an audio STN slot is a subtitle, not audio. - `mpls::parse_stream_entry` has a dedicated arm for this layout, so it is an - authored shape rather than a corruption. -* video (1 / 6 / 7 = primary, secondary, Dolby Vision EL) and IG (4) have no - `StreamLabelType`; they are numbered in their own STN lists and never - interleave with the audio or PG lists. - -## Test helpers and fixtures - -`playlist_with`: a playlist over clip "00001". `Playlist::streams` is read out -of the first play item's STN table, so a playlist that has streams always has -a play item to have read them from — the fixture carries one so tests exercise -the shape production sees, and so each label gets the `(clip, PID)` identity -it is bound by. - -`labels_from_playlists`: drives the actual production conversion logic -(`build_labels`, the function `parse()` calls) starting from already-parsed -Playlists, so tests don't have to synthesize valid on-disc MPLS/UDF bytes. -This calls the *real* code under test rather than a hand-written -re-implementation, so mutations inside `build_labels` (stream-type mapping, -dedup key, counters) are actually caught here. - -`padding_stn_entry_does_not_consume_a_label_slot`: `stream_number` is bound by -`labels::apply_labels` against the title's own stream list, which -`disc::bluray` builds from these same STN entries. That builder DROPS an entry -whose `coding_type` is 0 — the STN table's empty/padding slot — so it must not -be counted here either. Counting it advances the audio counter past a stream -that never materializes, and every label behind it binds one stream late. - -`pg_coding_type_in_an_audio_slot_counts_as_a_subtitle`: a PG coding_type -sitting in an audio STN slot is a real, documented shape — -`mpls::parse_stream_entry` has an explicit arm for it, and `disc::bluray` -builds it as a Subtitle stream, not an Audio one. This module must classify it -the same way, or the audio counter runs one ahead and the subtitle counter one -behind for every later stream. - -`same_pid_in_two_clips_is_two_streams`: the same PID in two DIFFERENT clips is -two different streams — a PID is only unique within one clip. Deduping on the -PID alone (as the old key's `(type, language, codec_hint, pid)` did across -clips) collapses them into one label, and the second clip's stream is then -described by the first clip's. diff --git a/docs/mpls.md b/docs/mpls.md deleted file mode 100644 index cb262f61..00000000 --- a/docs/mpls.md +++ /dev/null @@ -1,223 +0,0 @@ -# MPLS Playlist Format - -## What is MPLS? - -MPLS (Movie PlayList) files define playback titles on a Blu-ray disc. Each `.mpls` file in `BDMV/PLAYLIST/` describes one title -- a sequence of clips with precise in/out timestamps and stream information. The main movie, bonus features, trailers, and menus each have their own MPLS file. - -A disc may contain dozens of MPLS files. Most are short (menus, logos, transitions). The main movie is typically the longest playlist. libfreemkv filters out playlists shorter than 30 seconds. - -## File Structure - -MPLS files use big-endian byte order throughout. The file has three main sections: - -``` -Offset Size Field ------- ---- ----- -0 4 Magic: "MPLS" -4 4 Version: "0200" (BD) or "0300" (UHD BD) -8 4 PlayList start offset (absolute from file start) -12 4 PlayListMark start offset -16 4 ExtensionData start offset -``` - -### PlayList Section - -Located at the PlayList start offset. Contains all play items and sub-path entries: - -``` -Offset Size Field ------- ---- ----- -0 4 PlayList length -4 2 Reserved -6 2 Number of PlayItems -8 2 Number of SubPaths -10 ... PlayItem entries (variable length) -``` - -### Play Items - -Each play item references one clip and specifies what portion to play: - -``` -Offset Size Field ------- ---- ----- -0 2 PlayItem length (bytes after this field) -2 5 Clip ID (ASCII, e.g. "00001") -7 4 Codec ID ("M2TS") -11 1 Connection condition (lower 4 bits) -12 1 Ref to STC_id -14 4 IN_time (45kHz PTS ticks) -18 4 OUT_time (45kHz PTS ticks) -22 8 UO_mask_table -30 1 Misc flags -31 1 still_mode -32 ... STN_table (first play item only is parsed) -``` - -**Connection condition** values: -- `1` = seamless connection (no gap between clips) -- `5`, `6` = non-seamless connection - -**Timestamps** use 45kHz PTS (Presentation Time Stamp) ticks, the same timebase as MPEG transport streams. Duration of a play item = `OUT_time - IN_time`. To convert to seconds: divide by 45000. - -A playlist's total duration is the sum of all play item durations. - -### Clip ID Mapping - -The Clip ID (e.g. "00001") maps to: -- `BDMV/STREAM/00001.m2ts` -- the transport stream -- `BDMV/CLIPINF/00001.clpi` -- the clip info (EP map, stream details) - -## STN Table - -The Stream Number Table describes all elementary streams available in the clip. It is parsed from the first play item (which defines the title's stream layout). - -``` -Offset Size Field ------- ---- ----- -0 2 STN table length -2 2 Reserved -4 1 Number of primary video streams -5 1 Number of primary audio streams -6 1 Number of PG (subtitle) streams -7 1 Number of IG (interactive graphics) streams -8 ... Stream entries -``` - -### Stream Entries - -Each stream entry has two parts: a stream reference and a stream attributes block. - -**Stream reference:** - -``` -Offset Size Field ------- ---- ----- -0 1 Entry length -1 1 Stream type (1=PlayItem, 2=SubPath, 3=InMux) -2 2 PID (MPEG-TS packet ID, big-endian) -``` - -**Stream attributes** (immediately follows the reference): - -``` -Offset Size Field ------- ---- ----- -0 1 Attributes length -1 1 Coding type -2+ ... Type-specific fields -``` - -The layout of type-specific fields depends on the stream category: - -**Video streams:** - -| Offset | Size | Field | -|--------|------|-------| -| 1 | 1 | Coding type | -| 2 | 1 | Format (upper 4 bits) + frame rate (lower 4 bits) | - -**Audio streams:** - -| Offset | Size | Field | -|--------|------|-------| -| 1 | 1 | Coding type | -| 2 | 1 | Format (upper 4 bits) + sample rate (lower 4 bits) | -| 3 | 3 | Language code (ISO 639-2, e.g. "eng") | - -**PG subtitle and IG streams:** - -| Offset | Size | Field | -|--------|------|-------| -| 1 | 1 | Coding type | -| 2 | 3 | Language code | - -## Coding Types - -The coding type byte identifies the codec: - -| Value | Codec | Category | -|-------|-------|----------| -| `0x02` | MPEG-2 | Video | -| `0x1B` | H.264 / AVC | Video | -| `0x24` | HEVC / H.265 | Video | -| `0xEA` | VC-1 | Video | -| `0x80` | LPCM | Audio | -| `0x81` | AC-3 (Dolby Digital) | Audio | -| `0x82` | DTS | Audio | -| `0x83` | TrueHD (Dolby TrueHD) | Audio | -| `0x84` | AC-3 Plus (E-AC-3) | Audio | -| `0x85` | DTS-HD HR | Audio | -| `0x86` | DTS-HD MA | Audio | -| `0xA1` | AC-3 Plus (secondary) | Audio | -| `0xA2` | DTS-HD HR (secondary) | Audio | -| `0x90` | PGS (Presentation Graphics) | Subtitle | -| `0x91` | PGS (Interactive Graphics) | Subtitle | - -## Video Format Codes - -| Value | Resolution | -|-------|-----------| -| 1 | 480i | -| 2 | 576i | -| 3 | 480p | -| 4 | 1080i | -| 5 | 720p | -| 6 | 1080p | -| 7 | 576p | -| 8 | 2160p | - -## Video Frame Rate Codes - -| Value | Frame Rate | -|-------|-----------| -| 1 | 23.976 fps | -| 2 | 24 fps | -| 3 | 25 fps | -| 4 | 29.97 fps | -| 6 | 50 fps | -| 7 | 59.94 fps | - -## Audio Format Codes - -| Value | Channels | -|-------|----------| -| 1 | Mono | -| 3 | Stereo | -| 6 | 5.1 surround | -| 12 | 7.1 surround | - -## Audio Sample Rate Codes - -| Value | Rate | -|-------|------| -| 1 | 48 kHz | -| 4 | 96 kHz | -| 5 | 192 kHz | -| 12 | 48/192 kHz (combo) | -| 14 | 48/96 kHz (combo) | - -## How Playlists Map to Titles - -libfreemkv's `Disc::scan()` reads every MPLS file from the disc and builds a `Title` for each: - -1. Read all `.mpls` files from `BDMV/PLAYLIST/` via UDF. -2. Parse each with `mpls::parse()`. -3. Calculate duration by summing `(OUT_time - IN_time)` across all play items. -4. Discard playlists shorter than 30 seconds. -5. For each play item, load the corresponding CLPI file to get EP map data and compute sector extents (see [clpi.md](clpi.md)). -6. Extract stream info from the STN table of the first play item. -7. Sort titles by duration, longest first. - -The resulting `Title` struct contains everything needed to rip: streams, duration, byte size, and the sector extents to read from disc. - -## STN Table Secondary Block Alignment (test coverage note) - -The `full_stn_table_block_alignment` unit test exercises every STN category at once, with DISTINCT counts, so no count byte can be read from a neighbour's offset without changing the result. - -Each secondary block is followed by its reference block(s), which per the BD STN table are `num_refs(1) + reserved(1) + one byte per ref + one padding byte when the ref count is odd`. The test sets every ref count to 1 — the value that distinguishes `n % 2` (=1) from `n / 2` (=0) — so a wrong skip length misaligns the cursor and every following stream decodes from the wrong offset. IG entries are consumed to keep the cursor aligned but never retained. - -## References - -- BD-ROM Part 3, Section 5.3: PlayList file format -- https://github.com/lw/BluRay/wiki/MPLS diff --git a/docs/mux-disc.md b/docs/mux-disc.md deleted file mode 100644 index b1723aad..00000000 --- a/docs/mux-disc.md +++ /dev/null @@ -1,94 +0,0 @@ -# src/mux/disc.rs — internal notes - -Overflow rationale for internal (non-`pub`) items in `src/mux/disc.rs`, -relocated here by the comment-guard so the in-file comment can stay short. -Each section is pointed to by a one-line `// See docs/mux-disc.md — <topic>` -comment at the corresponding call site. - -## commit_read - -Commits a SUCCESSFUL `read_sectors` into the read buffer and advances the -extent cursor. `got` is the byte count the source reported (already clamped -to `bytes`, the full span requested for `sectors`). - -The full case (`got == bytes`) is the only one every in-tree `SectorSource` -produces — they are all full-or-error — and it behaves exactly as before. - -A SHORT read (`got < bytes`) used to be handled inconsistently: the returned -count was trusted for `buf_valid`, but `current_offset` still advanced by the -full requested `sectors`. The undelivered tail therefore vanished from the -muxed title with no error, no `SectorSkipped` event and no `lost_bytes` — a -silent hole, indistinguishable from clean output, in a single-pass path that -has no later pass to recover it. It is handled here rather than trusted away -because invisible data loss is the one outcome this stream must never -produce. - -The tail is NOT re-read from a smaller offset: `current_offset` must stay on -an AACS unit boundary (`unit_align`), and resuming mid-unit desyncs the -decrypt of everything after it — the same reason the failed-unit skip branch -below advances by the whole unit. So the gap is either - -- a hard [`crate::error::Error::DiscRead`] (E6000) when the caller has NOT - opted into holes, or -- zero-filled and ACCOUNTED under `skip_errors`, identically to a failed - unit: the stale buffer tail is cleared, `errors` / `lost_bytes` are - charged, and a `SectorSkipped` event is emitted. - -## a_long_run_of_empty_extents_does_not_recurse_per_extent - -Nothing filters `sector_count == 0` out of a UDF/MPLS extent list, so a -malformed disc can declare a long run of empty extents. `fill_extents` used -to skip each one with a self-recursive call, costing a stack frame apiece; -Rust does not guarantee tail-call elimination, so a few thousand of them -overflowed the stack. A stack overflow aborts the process — it is not -catchable and takes the whole `autorip` service down — where the iterative -form just walks off the end and reports EOF. - -Run on a deliberately small stack so the frame cost is unmissable: the -recursive version dies here, the loop finishes in microseconds. - -## bad_sector_keeps_its_identity_across_the_prefetch_channel - -REGRESSION (round-4 audit): an ordinary MEDIUM ERROR bad sector must keep its -identity when it crosses the prefetch producer channel — the same `DiscRead` -with its SCSI status, NOT a transport failure. - -`PrefetchedSectorSource::read_sectors` re-wrapped every error that crossed -the channel as `Error::IoError`, and `is_scsi_transport_failure` matches -`IoError` (the wedged-USB-bridge arm). So a bad sector reached `fill_extents` -looking like a dead bus and aborted the pass with a fabricated status 0xFF — -the exact inverse of what that short-circuit exists for, and it told the -user to power-cycle a healthy drive. - -Asserted on the source, not on a `fill_extents` skip: the producer thread -exits for good after sending an error, so nothing downstream of it can -genuinely recover the rest of the title (see -`dead_prefetch_producer_does_not_silently_zero_fill_the_title`). An assertion -that the pass continues could only ever have been satisfied by fabricated -zeros. - -## dead_prefetch_producer_does_not_silently_zero_fill_the_title - -The prefetch producer thread terminates PERMANENTLY on its first read error, -so once one bad sector has crossed the channel the source can never deliver -another byte. Driving `fill_extents` to exhaustion after that must NOT look -like a completed pass: every remaining sector would be fabricated zeros, and -DATA LOSS MUST NEVER LOOK LIKE SUCCESS. - -The expectation is the product rule, not the code: a source that is -permanently out of data must report that, not answer `Ok(0)` forever — which -`commit_read` legitimately reads as an ordinary short read and zero-fills. - -## errors_reports_frames_the_resync_gate_dropped_after_the_gap_resolves - -Frames the B1 resync gate discards must reach `errors()`, including after -the gap RESOLVES. - -`ResyncGate::dropped` is zeroed the moment a keyframe disarms the gate, and -the only EOF warning fires for gates STILL armed — so a mid-title gap that -resolves left no trace anywhere. That is the common case: most gaps do -resolve. A rip with several concealed gaps reported 0 errors and 0 lost -bytes while whole GOPs were discarded. - -This asserts the accessor, not the gate's own counter, because `errors()` is -the only channel through which a caller learns anything went wrong. diff --git a/docs/mux-driver.md b/docs/mux-driver.md deleted file mode 100644 index 9b7976d0..00000000 --- a/docs/mux-driver.md +++ /dev/null @@ -1,235 +0,0 @@ -# `src/mux/driver.rs` — design notes - -## Overview - -High-level mux driver — the one place that runs the -`construct → headers gate → open sink → pump → finish` pipeline the -consumers (CLI `pipe`/`pipe_disc`, autorip `run_mux`) each hand-rolled. - -`mux_stream` DRIVES the existing highway; it does not replace it. For a -file/ISO source it constructs the SAME -[`build_iso_pipeline`](../src/mux/resolve.rs) 3-stage -prefetch → demux → parse chain the consumers build today (the 660 MB/s -highway), reads frames off it exactly where the consumers call -`stream.read()`, and writes them through a -`WRITE_PIPELINE_DEPTH`-deep write `Pipeline` so the latency-bound sink -write overlaps the next read. No wrapper is inserted around the reader or -the frames — the only threads are the three inside `build_iso_pipeline` plus -the single write consumer, exactly as in the consumers today. - -## Gate ordering (bug fix) - -The `chapters://` / `json://` metadata sinks write their whole file from the -scanned title at `output()` time and consume no PES frames, so they need no -codec headers. The CLI put that short-circuit AFTER the `headers_resolved` -gate (`pipe.rs`), so a metadata export on a title whose video headers never -resolved failed with `MkvInvalid`. Here the short-circuit runs BEFORE the -header pump/gate — by construction a metadata sink can never trip the header -gate. - -## `NO_SEND_DEADLINE` - -A slow-but-alive downstream — a paused pager, a backpressured pipe, a slow -network sink — must NOT be reported as an interrupted mux, which is exactly -what a finite per-frame deadline would do after that much backpressure. -`None` therefore means "block on backpressure, don't time out", while still -honouring the halt token for Ctrl-C / `/api/stop`. - -This is safe because `Pipeline::send_with_halt` slices its wait at -`crate::halt::POLL_INTERVAL` (250 ms) internally and re-checks the halt -token on EVERY slice — halt responsiveness is independent of the deadline -value. So a huge (but finite, `Instant`-overflow-safe) duration blocks -indefinitely on a live-but-slow sink yet still unwinds within ~250 ms of a -halt. ~10 years is far past any real mux; a literal `Duration::MAX` risks -panicking the `Instant::now() + deadline` addition inside `send_with_halt`. - -## `MuxOptions::send_deadline` - -`None` means no backpressure timeout: the send blocks as long as the -downstream is alive (only a `halt` interrupts it). The CLI's interactive -stdout / network sinks pass `None` so a slow-but-alive consumer (paused -pager, backpressured pipe, slow peer) is never spuriously reported as an -interrupted/incomplete mux — matching the pre-refactor inline -`output.write()` which had no deadline. - -## `MuxEvents` event-firing threads - -The driver fires `Self::on_output_opened` and the write-side -`Self::on_write_progress` from the driving thread; the reader-side events -(`Self::on_sector_skipped` / `Self::on_batch_size_changed` / -`Self::on_read_error`, plus `Self::on_read_progress`) are fired by the -cloned `EventFn` from the highway's producer thread / the live -`DiscStream`'s read loop. - -Progress is split into two callbacks because consumers drive their progress -UI from different sides of the pipeline: the CLI renders from the WRITE side -(bytes finalised to the sink), autorip from the READ side (bytes pulled off -the disc). Keeping them distinct avoids the old ambiguous single -`on_progress(bytes, total)` where the caller couldn't tell which number it -was handed. - -## `MuxOutcome::undelivered_streams` - -Non-empty means the file does NOT match the pre-mux plan -(`mp4_fit_report`), even with `completed = true`: those streams are -missing. Always empty when the run stopped before the sink was finalised -(nothing was finished, so nothing is known). - -## `mux_run_completed` - -A clean operator stop (`interrupted`), a wedged/halted finalize -(`finalize_failed` — the write `Pipeline` returned -`Halted`/`PipelineJoinTimeout` from `finish`), or a halt cancellation each -force `completed = false`, so the consumer runs its stop-preserves-staging -path instead of reporting a truncated file as done. - -Extracted as a pure fn because the `finalize_failed` branch is otherwise -reachable only through real write-thread wedge timing (the -internally-built `WriteSink` offers no seam to force a `finish` timeout -deterministically), so the mapping is unit-tested here directly. - -## `session_mux_keys` - -CSS title keys are per-VTS, and `Disc::decrypt_keys` returns the LARGEST -title's VTS key — muxing a bonus title in a DIFFERENT VTS with it -descrambles to corrupt MPEG-PS. `resolve_dvd_title_key`'s crack-guard only -fires when `keys` is `None`, so passing the whole-disc key would skip the -per-title crack entirely. This mirrors the sibling `resolve.rs::input()` -DVD special-case (and the pre-refactor CLI `pipe.rs`). AACS / genuinely-clear -discs resolve from `decrypt_keys()` with no read. - -## `resolve_inline_base_map` - -Resolves the base AACS key map for an INLINE live-drive mux (the `Session`/ -`Live` arms) BEFORE the reader is moved into `DiscStream::new` — the -counterpart to the map resolution `build_iso_pipeline` performs internally -for the file highway. - -Under the map-only decrypt model an AACS `DecryptingSectorSource` decrypts -NOTHING until a key map is installed: with no map the AACS arm of the -decrypt path fails loud with `Error::DecryptFailed` on the first content -unit (the deliberate "a reader built without its map is a bug" guard). Both -inline arms used to skip this — `Session` installed no map at all, and -`Live` installed only a caller-supplied forensic FMTS map (`None` for a -plain AACS disc) — so EVERY plain AACS Blu-ray/UHD muxed via the live -single-pass path failed `DecryptFailed` on the first content read. This -resolves + returns the map so the caller can install it via -`DiscStream::with_key_map`. - -- AACS keys → resolve (`resolve_mux_key_map`: single-CPS content map, - multi-CPS per-extent key selection, or FMTS per-segment map) and return - `Some(map)`. Resolution failure propagates (fail loud), matching the ISO - path's decrypt gate. -- CSS / clear / `None` → `Ok(None)`: CSS self-cracks per title inside - `DiscStream::new`, and a genuinely-clear disc needs no map. -- `raw` → `Ok(None)`: ciphertext passthrough, no decrypt step to key. - -The `reader` is borrowed only to SAMPLE ciphertext here (the UDF/FMTS probe -and any multi-CPS unit samples); a single-CPS disc — the overwhelming -majority, including every single-key UHD — resolves its map with NO content -read beyond the one-time UDF filesystem probe. The caller then moves the -same reader into `DiscStream::new`; reads are by absolute LBA, so the -sampling leaves no read-position state behind. - -## `reader_event_fn` - -Cloning the `Arc` into the returned closure is precisely what lets a -borrowed-lifetime consumer's events reach the highway's producer thread — a -`&dyn MuxEvents` borrow cannot satisfy the `'static` bound. - -Mapping (real `EventKind` variants): -- `BytesRead { bytes, total }` → `MuxEvents::on_read_progress` (read-side; - the file highway's only reader event — `total` is the extents' byte total) -- `SectorSkipped { sector }` → `MuxEvents::on_sector_skipped` (live only) -- `BatchSizeChanged { new_size, reason }` → `MuxEvents::on_batch_size_changed` - (live only) -- `ReadError { sector, .. }` → `MuxEvents::on_read_error` - -Sector numbers are the library's `u64`; the `MuxEvents` LBA hooks take `u32` -(the disc's LBA space), so they are narrowed with `as u32`. - -## `mux_input_live_uses_inline_discstream_and_applies_key_map` test - -`MuxInput::Live` builds the INLINE `DiscStream` (not the highway) AND -applies the forensic `key_map` before reading. Proof is the recorded LBA -set: `with_key_map` rewrites the extent walk via `AacsKeyMap::read_plan` so -the alternate-phase (odd) forensic units are NEVER fetched off the source. -The zeroed reader can't resolve codec headers, so the mux drains to a -`NoStreams`/`MkvInvalid` refusal — irrelevant here; the test asserts only -the read plan the reader actually executed. - -Mutation: deleting the `stream = stream.with_key_map(map)` line in the -`Live` arm leaves the extents un-rewritten, so the reader DOES fetch the -dropped odd-phase LBAs and the "never read" assertion below fails. (A -second mutation — routing `Live` through `build_iso_pipeline` instead of -`DiscStream::new` — would not compile against a `Box<dyn SectorSource>` and -drop the inline `fill_extents` retry entirely.) - -## `mux_input_live_aacs_without_caller_map_resolves_and_decrypts` test - -END-TO-END decrypt on the live single-pass `MuxInput::Live` path with a -plain (non-FMTS) AACS disc and NO caller-supplied key map — the exact shape -of `freemkv rip disc://…` and autorip's non-FMTS single-pass. The driver's -`Live` arm must RESOLVE + INSTALL the base AACS key map itself; the unit -then decrypts to a valid audio PES and the mux drains and finalises. - -This is the regression guard for the confirmed bug: without the map the -AACS `DecryptingSectorSource` fails `DecryptFailed` on the first content -unit and no AACS disc could ever be live-muxed. - -Mutation: deleting the `resolve_inline_base_map` call (or the -`stream = stream.with_key_map(map)` install) in the `Live` arm leaves the -reader mapless → the first content batch cannot decrypt (root cause -`DecryptFailed`, surfaced through `fill_extents`' non-skip read-error path -as a `DiscRead`) → `mux_stream` returns `Err` and `out.completed` is never -reached (verified: the mux aborts instead of finalising). - -## `mux_input_session_aacs_without_caller_map_resolves_and_decrypts` test - -END-TO-END decrypt on the live single-pass `MuxInput::Session` path — the -exact shape of `freemkv rip disc://…mkv`. The `Session` arm runs the SAME -sequence as `Live` (take_reader → resolve_inline_base_map → DiscStream → -with_key_map), but until now had NO end-to-end coverage because a real -`DiscSession` needs a live `Drive`. Using the `#[cfg(test)]` -`from_parts_for_test` constructor, a genuinely-AACS-encrypted unit muxed -through the `Session` arm must resolve+install the base key map itself and -DECRYPT to a valid audio PES. - -Mutation: dropping `stream = stream.with_key_map(map)` (or the -resolve_inline_base_map call) in the `Session` arm leaves the reader -mapless → the content batch cannot decrypt → the mux aborts (`Err`) and -`out.completed` is never reached. - -## `finalize_failed_forces_incomplete_outcome` test (FIX 3) - -A mux whose read side drained cleanly (`interrupted = false`, halt not -cancelled) but whose write pipeline WEDGED on finish (`finish_with_halt` → -`Err(Halted | PipelineJoinTimeout)` → `finalize_failed = true`) must fall -through to `completed = false` — never surface a truncated file as a -finished rip. - -Mutation: dropping `finalize_failed` from `mux_run_completed`'s condition -makes `mux_run_completed(false, true, false)` return `true` → this fails. - -## `mux_input_session_out_of_range_title_is_clean_error_not_panic` test (FIX 4) - -`MuxInput::Session` with a `title_index` past the disc's title count must -surface a clean `Error::MuxTrackRange` (code E9011), NOT panic on the -out-of-range `titles.get(idx)`. Everything else is valid (disc scanned, -reader staged) so the range guard is the sole failure. - -Mutation: replacing the `.ok_or(MuxTrackRange…)?` guard with `.unwrap()` -panics on the out-of-range index → this test fails. - -## `HEADER_BUFFER_CAP_BYTES` - -Normally `headers_ready()` resolves after a handful of frames; a -damaged/undecryptable title whose video `codec_private` never resolves -would otherwise grow the pre-headers buffer without bound (the whole -30-90 GB title, one PES frame at a time) until the process is OOM-killed. -512 MiB is far more than any real codec-private resolution needs but small -enough to fail fast rather than swap the box to death. Once exceeded the -mux is refused with `Error::MuxHeaderBufferExceeded` — its OWN code, not -the headers-never-resolved gate's `Error::MkvInvalid`, which -`error::is_skippable_title_stub` reports as a skippable stub. Mirrors -autorip's pre-refactor `HEADER_BUFFER_CAP_BYTES`. diff --git a/docs/mux-select.md b/docs/mux-select.md deleted file mode 100644 index 71c7755e..00000000 --- a/docs/mux-select.md +++ /dev/null @@ -1,54 +0,0 @@ -# src/mux/select.rs — internal notes - -Overflow rationale relocated here by the comment-guard so the in-file -comments can stay short. Each section is pointed to by a one-line -`// See docs/mux-select.md — <topic>` comment at the corresponding site. - -## Module rationale - -The demux pipeline is declaration-driven: every demux table -(`build_demux_state` in `mux/resolve.rs`, `DiscStream::new` in `mux/disc.rs`) -is built from the input `DiscTitle`'s `streams` list, and the MKV writer -builds its track headers + `codec_privates` from that same list. A PID not -declared there is never tracked, extracted, or written. So "which streams to -keep" is already a capability of the pipeline — it just has no public knob. - -`StreamSelection::apply` is that knob: prune the `DiscTitle.streams` list -(video always kept) BEFORE the mux path finalizes the title, and everything -downstream — track headers, `codec_privates`, PID routing, frame emission — -follows from the pruned list by construction, with zero scattered PID -checks. This is language-agnostic: PIDs, not languages (the language→PID -mapping is the caller's/engine's policy). - -## `StreamSelection::apply` rationale - -The parallel `codec_privates` vec is pruned in lockstep when it is -populated (it is empty on a freshly-scanned title, non-empty only if a -caller pre-filled it) — by index, whatever its length, since it is consumed -positionally and a partial prune would attach the wrong codec-private to a -retained track. - -`Error::SelectionPidUnknown` is returned rather than silently emitting an -MKV missing a requested track — fail loud on a caller bug (e.g. a stale -scan) instead. - -## codec_privates length-mismatch regression (test) - -A `codec_privates` vec that is NOT exactly stream-length must still be -pruned in lockstep. `m2ts.rs::create` documents a longer-than-streams vec -as a benign shape ("ignore any trailing entries that exceed the track -count"), and the vec is consumed positionally, so skipping the prune left -`codec_privates[i]` describing a stream that is no longer at index `i`. - -Regression: with one trailing extra entry the prune was skipped entirely -and index 1 — the retained `fra` audio — resolved to `eng`'s record, so -the muxer attached the wrong codec-private to the track. No error, no log. - -## Wrong-class filter PID regression (test) - -A PID listed in the wrong class's filter must fail loud, not validate and -then quietly vanish. Validation used to scan both audio and subtitle -streams, so an audio filter naming a subtitle PID passed — and `keeps` -then matched it against audio streams only, dropping the requested track -from the output with no error. That defeats the documented "fail loud -rather than silently emit an MKV missing a requested track" contract. diff --git a/docs/mux-timeline.md b/docs/mux-timeline.md deleted file mode 100644 index a72c994d..00000000 --- a/docs/mux-timeline.md +++ /dev/null @@ -1,634 +0,0 @@ -# `src/mux/timeline.rs` — design notes and audit history - -Long-form rationale relocated here by the comment guard. Each section is -pointed to from a short `//`/`///` comment at the matching site in -`timeline.rs`. - -## Module overview - -There are two ways to place a clip's frames on the output timeline: - -- **From the playlist's marks** (`SeamPlan`) — when the title carries - PlayItem IN/OUT times, each clip contributes exactly `out - in` and the - clips are laid end to end. This is exact: it closes forward skips, joins - overlaps without rewinding, and drops material the playlist excludes. -- **By inference** (`TimelineContinuity::adjust`) — when there are no usable - marks (DVD, HD-DVD, `mkv://` / `m2ts://` sources), a backward PTS jump - larger than `DISCONTINUITY_BACKSTEP_NS` is read as a join and rebased. - Inference cannot see a forward skip, because a forward gap is - indistinguishable from frames lost to damaged media, and cannot see an - overlap smaller than the reorder threshold. - -`TimelineContinuity::map` picks between them: marks when present, inference -otherwise. Every muxer/sink that consumes the interleaved per-track PES -stream and emits a monotonic timeline (the MKV muxer, the `demux://` -elementary-stream sink) goes through it, so the correction lives in exactly -one place. - -## `DISCONTINUITY_BACKSTEP_NS` - -A backward PTS step larger than this is treated as a clip-boundary -discontinuity (a non-seamless BD clip / dual-layer-break where the source -PES PTS resets), NOT as B-frame reorder. HEVC/H.264 reorder depth tops out -around 16 frames (<1s at 24 fps); 3s sits comfortably above any legitimate -reorder window and far below any real clip's duration, so it never -false-triggers within a clip. - -## `CLIP_START_TOLERANCE_NS` - -How close a frame's PTS must be to a clip's IN mark to be recognised as that -clip's opening frame. - -At an OVERLAP join the next clip's IN sits inside the current clip's range, -so "past the current OUT" never fires and the two clips share a PTS band. -The clips are concatenated in file order, though, so the new clip opens ON -its IN mark — this window is what tells that opening frame apart from the -old clip's tail. One video frame is ~42 ms at 24 fps; 250 ms allows for a -clip whose first frame sits a few frames past its mark without ever reaching -the next join. - -## `SeamClip::feed_span` - -Byte range this clip occupies in the title's feed, when known. - -When a frame carries its source offset this makes clip assignment a LOOKUP. -Inside an overlap two clips' mark ranges both contain the same timestamp, so -no rule over timestamps alone can say which clip a frame came from — four -audit rounds each fixed one such rule and broke another. The byte offset -falls in exactly one span. - -## `SeamPlan` (struct doc) - -The playlist's own answer to "where does each clip belong on the timeline". - -A seamless-branching title's PlayItems do NOT chain contiguously in the -shared clock: one clip's OUT may sit *after* the next clip's IN (overlap — -the disc stores the join twice so a player can switch without a gap), or -*before* it (skip — the playlist jumps over material). Measured on one real -UHD title, `00801.mpls`, 11 PlayItems: - -```text -clip 0 in 4199.0000 out 6033.0405 cum_start 0.0000 -clip 1 in 6031.2500 out 6308.1933 cum_start 1834.0405 <- 1.79s OVERLAP -clip 2 in 6298.1667 out 6875.0763 cum_start 2110.9839 -clip 3 in 6884.2500 out 6948.0220 cum_start 2687.8935 <- 9.17s SKIP -``` - -Inferring seams from PTS jumps cannot recover this. A forward jump is -ambiguous — it means "the playlist skipped" OR "we lost frames to damaged -media", and compressing the latter would silently falsify timing on exactly -the rips that most need it faithful. An overlap smaller than the B-frame -reorder threshold is invisible to inference entirely, and its duplicated -content then collides in the muxer. - -So the marks are read rather than guessed. Each clip contributes exactly -`out − in` to the output, laid end to end: gaps never become dead timeline, -and material outside a clip's marks is dropped rather than emitted twice. - -## `SeamPlan::spans_trusted` - -Whether the per-clip feed spans can be trusted to identify a clip from a -frame's byte offset. - -True only when the spans tile the feed contiguously from 0 with no gap or -overlap. Anything else means the scan's view of the extents and the mux's -differ, and a byte offset would then select a confidently WRONG clip for -every frame — a worse failure than the mark heuristics, which are at least -approximately right. In that case provenance is disabled and the heuristics -are used, which is the 1.6.0 behaviour. - -## `SeamPlan::dropped` - -Frames dropped because they fell outside every clip's marks, per track. - -A drop is correct — the playlist does not include that material — but a -SILENT drop is how this codebase has produced complete-looking, wrong output -before. Counting them means an unexpected volume shows up in the log instead -of in someone's file, and gives a caller something to assert on. Indexed by -track alongside `cursors`. - -## `SeamPlan::cursors` - -Per-track position: (clip index, last raw PTS seen). - -Each track crosses a join on ITS OWN frame, not on video's. The demuxer -interleaves the tracks, so when video enters the next clip the previous -clip's audio and subtitle tails are still arriving — and at an OVERLAP join -those tail frames fall inside BOTH clips' mark ranges, so there is no way to -place them from the PTS alone. Sharing one cursor gave the tail the new -clip's offset, which threw it forward by the overlap and made it collide -with the new clip's own frames; the muxer's monotonic nudge then flattened -the collision onto the tick floor, which is exactly the audio-ahead-of-picture -symptom this type exists to remove. - -## `SeamPlan::from_clips` - -Build a plan from a title's clips, or `None` when there is nothing to place: -no clips (DVD, HD-DVD, `mkv://`/`m2ts://` sources — none of which carry -PlayItem marks), or marks that are not usable (a zero/inverted span means the -playlist is not telling us anything we can act on, and guessing is what this -type exists to avoid). - -A **single** clip still gets a plan. Joining is not the marks' only job — -trimming to `[in, out]` is — and it matters whenever a clip's physical extent -runs past its OUT mark. Real discs author trailing audio (a fade after the -last video frame) beyond OUT in the m2ts; without a plan the inference path -keeps it, leaving audio seconds past the declared duration. One clip needs no -cross-clip placement, so the loop below reduces to the `[in, out]` drop -filter plus the standard `offset = −in_ns` rebase — the same one clip 0 of a -multi-clip title already gets, which the MKV muxer then re-anchors, so every -KEPT frame is byte-identical to the no-plan path. The only change is that -out-of-mark frames are now dropped. - -Returning `None` leaves `TimelineContinuity` on its PTS-jump inference, which -is what every non-BD path has always used. - -## `SeamPlan::clip_at_byte` - -Which clip owns feed byte `b`, by BINARY SEARCH. - -`spans_trusted` guarantees the spans tile the feed contiguously in order, so -this is a partition point rather than a scan. That matters: discs in the test -corpus reach 900 clips, and this runs once per frame per track — a linear scan -would be ~900 comparisons on every one of millions of frames, which is real -time spent for no reason. - -A repeated clip reuses its first reference's span, so the search lands on the -FIRST entry with that span. The bytes are only read once, so the material is -emitted once, at that entry's offset. - -## `SeamPlan::clip_in_run_for` - -Pick the member of a shared-span run whose marks contain `raw_ns`. - -`clip_at_byte` deliberately answers with the FIRST entry of a run so the -lookup is stable. That is the right answer when the run is one clip; when -several PlayItems reference one file it is only the right STARTING point, -because they differ solely in their marks. Falls back to the first when none -contains the timestamp, which keeps the existing drop behaviour for material -genuinely outside every reference. - -## `TimelineContinuity` (struct doc) - -Global timeline corrector. - -Holds a `SeamPlan` when the title's PlayItem marks are usable, and falls back -to the PTS-jump inference described below when they are not. The inference -documentation that follows applies to the FALLBACK path only — under a plan, -placement comes from the marks and none of the epoch/frontier reasoning below -decides anything. - -freemkv reads a BD title's clips as one concatenated sector stream, so at a -non-seamless boundary the source PES PTS jumps backward. Left uncorrected, -that produces a sustained band of non-monotonic block timestamps (a -downstream muxer then derives non-monotonic DTS from them). - -A single running `offset_ns` is applied to EVERY track, so the concatenated -clips form one monotonic timeline AND A/V sync is preserved (all tracks at a -boundary shift by the same amount). It is global, not per-track: a clip -boundary resets every stream together by the same delta. - -**Only the VIDEO track drives epoch decisions.** A title carries one video -track plus many interleaved audio + subtitle tracks (one UHD title: 2 video, -11 audio, 32 PGS). Those non-video tracks are sparse and lag the video by -seconds, so their raw PTS swing well over the 3 s discontinuity threshold -against a shared frontier even within a SINGLE clip — a late subtitle PTS -would ratchet `high_ns` up, then the next normal video frame would sit >3 s -below it and be misread as a clip boundary, permanently bumping `offset_ns`. -That false-positive ratchet (firing thousands of times on a one-clip title) -inflated that title's cluster/Cue timestamps into the billions of ms and -destroyed its seek index. The clip-boundary INFERENCE is therefore keyed on -video PTS alone: video establishes and advances the frontier and is the only -track that can open a new epoch. Non-video frames are remapped under the -CURRENT offset and never touch the frontier or the offset — they ride the -timeline the video defines, preserving A/V sync (all tracks at a boundary -shift by the same delta) without ever triggering a rebase themselves. - -The demuxer interleaves the tracks, so at a real (multi-clip) boundary the -streams do NOT all reset on the same frame — a lagging audio/PGS frame from -the just-ended clip's tail can arrive AFTER the next clip's video has already -reset the epoch. Such a "straggler" carries an old-epoch raw PTS; adding the -new (clip-sized) offset to it would fling it far past the frontier and force -a forward-dated split cluster. A non-video frame whose mapped position lands -more than a backstep past the frontier is therefore clamped to the frontier -(the seam) — it never perturbs the offset or the frontier and never -forward-dates a cluster. Genuine multi-clip seamless rebasing (the design -that is correct for real HEVC/H.264 multi-clip titles) is preserved: it is -the video back-jump that opens a new epoch, exactly as before. - -## `TimelineContinuity::prev_offset_ns` - -Offset (ns) of the immediately previous epoch — used to recognise and remap a -non-video tail straggler at a boundary (an old-epoch frame whose -current-offset mapping flies forward but whose previous-offset mapping lands -at the seam). Equals `offset_ns` until the first boundary. - -## `TimelineContinuity::epoch_offsets` - -Every epoch already left behind, oldest first, as -`(offset, frontier when it closed)`. A straggler carries a raw PTS from one -of THESE, and a single `prev_offset_ns` cannot name the right one once a -title has more than two epochs. The closing frontier is what makes the test -meaningful: a straggler sits at the TAIL of its own epoch, which is only -recognisable against that epoch's end — not against the current frontier, -which by then may be a whole title away. Bounded: a source that rebases -forever must not grow this. - -## `TimelineContinuity::last_raw_ns` - -Last raw PTS seen per track, for spotting a track's OWN discontinuity. Within -an epoch a passive track's PTS only advances (audio and subtitles do not -reorder, and a passive video track's B-frame dip is far under the backstep), -so a large BACKWARD step is unambiguous. This is a different signal from the -shared frontier, which is what the old false-positive ratchet keyed on. - -## `TimelineContinuity::provisional` - -Per-track provisional offset for frames that arrive BEFORE the video frame -opening their epoch, as `(epochs retired when it was taken, offset)`. - -It is deliberately private to one track and never written to `offset_ns`, -never advances `high_ns`, and never retires an epoch — so it cannot move the -video timeline. Letting a passive track open a real epoch was tried and -inflated a 476.776 s title to 656.216 s, because every track observes a -boundary at its own pace and the video path rebased again on top of whatever -they had done. - -## `TimelineContinuity::with_clips` - -Falls back to `Self::new`'s inference when the title has no clips or its -marks are unusable — so DVD, HD-DVD, `mkv://` and `m2ts://` sources behave -exactly as before. A single BD clip DOES get a plan: its marks still trim -trailing/pre-roll material outside `[in, out]`. - -## `TimelineContinuity::dropped_total` - -Zero for a title without a seam plan. A muxer reports this when it finishes -so a drop is never invisible: dropping is correct at a join, but an -unexpected VOLUME of drops is how output ends up quietly short. - -## `TimelineContinuity::map` - -Dropping only ever happens under a `SeamPlan`: it is material outside the -playlist's marks, which the title does not include. - -## `TimelineContinuity::passive_offset` - -The offset a passive frame should ride, and the bookkeeping around it. - -Returns the effective offset for THIS frame. Normally that is the current -epoch's. It differs only for a frame that arrived ahead of the video that -opens its epoch: such a frame's own raw PTS has just jumped backwards AND its -current-epoch mapping lands a whole epoch below the frontier, which no -in-epoch frame ever does. - -A provisional is dropped the moment the video actually retires an epoch, so -the run rejoins the real offset with no seam — the two agree because both are -`frontier - mapping + gap`. - -## `TimelineContinuity::open_epoch` - -Retiring records the offset AND the frontier the epoch closed at. The closing -frontier is what later makes a straggler recognisable: it says where that -epoch's tail was, which the current frontier cannot, since by then it may be -a whole title further on. - -## `TimelineContinuity::straggler_offset` - -The offset of the epoch a straggler actually belongs to. - -A frame whose current-epoch mapping flies past the frontier carries a raw PTS -from an epoch already left behind. Pick the retained epoch that lands it -CLOSEST BELOW the frontier: that is where the just-ended epoch's tail was, -and it can never forward-date a cluster. `None` when no retained epoch places -it sanely, in which case the caller keeps the current mapping — which is -exactly the pre-existing behaviour, so a title that never rebased is -unaffected. - -## `TimelineContinuity::adjust` - -`drives_epoch` gates EVERY epoch decision. It is `true` for the PRIMARY video -track (base layer, track 0) ONLY. Every other track — audio, PGS subtitle, -and a second video track such as a Dolby Vision enhancement layer — passes -`false` and is a passive rider. (The DV EL is video but runs its own PTS -timeline interleaved with the base layer's; letting it drive epochs would -false-trigger a reset on every GOP.) - -**Passive tracks** (`drives_epoch == false`). Always remapped under the -CURRENT offset. They never advance `high_ns`, never trigger a clip-boundary -reset, and never bump `offset_ns`. This is what kills the single-clip -ratchet: a sparse/lagging subtitle/audio PTS, or an interleaved EL frame, can -no longer push the frontier up and make the next base-video frame look like a -boundary. A/V sync is preserved because the offset they ride is the same one -the base video established for the epoch. - -**Primary video** (`drives_epoch == true`): -- **Backward jump > `DISCONTINUITY_BACKSTEP_NS`** vs the frontier = clip- - boundary reset: open a new epoch (bump the offset so this frame continues - just after the frontier). This is the genuine multi-clip seamless - rebasing, now driven only by real base-video back-jumps. -- **Everything else** (normal progression + sub-threshold B-frame reorder - dips) passes through with the current offset and advances the frontier, - preserving PTS. - -## Test module — audit findings and measured regressions - -The `#[cfg(test)]` module below documents, per test, an audit finding or a -measured real-disc regression that motivated it. Each test's full rationale: - -### `continuity_rebases_clip_boundary_reset` -Characterization of the BUG: a BD title's two clips concatenated with a PTS -reset at the boundary. WITHOUT correction the raw VIDEO timeline goes hard -backward at clip 2 (what produced the non-monotonic-DTS band on multi-clip -UHD titles). WITH `TimelineContinuity` the output is monotonic and continuous -across the boundary. The boundary is driven by VIDEO. - -### `a_clip_join_never_rewinds_the_output_timeline` -Audit finding, measured against the real 00801.mpls marks: with the cursor on -clip 5 (7708.99..7910.79) a frame at 7845.00 — clip 6's pre-mark lead-in, 8s -below clip 6's IN of 7853.00 — was more than the 250ms tolerance from that -mark, so no crossing rule fired; the cursor stayed on clip 5, and 7845.00 IS -inside clip 5's range, so the frame was PLACED with clip 5's offset. Output -went backwards 65s and dropped stayed 0 — no counter, no gate, nothing -noticed — while the entire overlap band was emitted a second time over clip -5's written tail. - -A backward step larger than `DISCONTINUITY_BACKSTEP_NS` cannot be B-frame -reorder, so it is a new clip's file starting. Such a frame is either placed -on the clip that contains it, or dropped as pre-mark material the playlist -excludes — never emitted behind the frame before it. - -### `a_glitched_pts_does_not_strand_a_track_on_a_later_clip` -Audit finding against the large-backstep branch: advancing on ANY >3s -backward step also fires for a corrupt PTS, or a legitimate STC -discontinuity inside one clip. Nothing moves the cursor back — a forward step -matches neither `past_out` nor `stepped_back` — so every later frame sits -below the new clip's IN and is dropped. On this table that is ~17 minutes of -one track, and the only volume gate compares total drops against ALL tracks' -frames, so it exits 0. - -The branch now requires the frame to be INSIDE the current clip's marks, -which is the only case that would otherwise be wrongly placed. - -### `output_never_rewinds_and_a_bad_frame_never_strands` -The three placements audit round 7 enumerated, which the previous -mark-heuristic guards all got wrong in different ways. - -The property asserted is the one that matters and the one the heuristics -only approximated: a track's output never runs backwards, and a bad frame -never strands the cursor. Numbers are from the real 00801.mpls table. - -### `plan_offset_for` (test helper) -The output offset `from_clips` computes for a clip: the sum of every earlier -clip's playable duration, minus its own IN. - -### `provenance_picks_the_clip_the_frame_came_from_inside_an_overlap` -The case four rounds of mark heuristics could not get right: inside an -overlap, clip k's OUT is AFTER clip k+1's IN, so one timestamp is valid in -both. The byte offset says which clip the frame actually came from. - -### `all_tracks_of_one_clip_agree_under_provenance` -Every track of a clip lives in the SAME stream file, so provenance makes -video, audio and subtitles agree by construction. Divergence between them — -each track guessing separately under its own rule — is how audio and video -ended up on different clips and drifted apart. - -### `a_clip_split_across_two_play_items_keeps_both_halves` -A playlist may reference the same clip twice (a looped segment). The bytes -are read once, so both entries share one span — that must not be read as a -broken map. - -A clip FILE referenced by two adjacent PlayItems — a seamless split, a -looped segment, multi-angle — is one file with one set of bytes, so it has -ONE feed span. But each PlayItem carries its OWN marks, and the two cover -different halves of it. - -Provenance alone cannot tell those halves apart: every frame of the file -resolves to the same span. `clip_at_byte` answers with the FIRST of the run, -so without the timestamp to disambiguate, every frame past the first -PlayItem's OUT is judged against marks it was never inside and dropped — the -second half of that clip silently missing from the rip, with the timeline -still charged for its duration. - -### `no_provenance_still_places_by_marks` -A source that stamps no provenance (a mkv:// remux, the deserialize hop) -must still work — it takes the mark heuristics, which is what it has always -used and where it has always been correct, because such sources have no -overlapping clips to be ambiguous about. - -### `provenance_agrees_with_marks_wherever_marks_are_unambiguous` -On frames that are NOT ambiguous, provenance and the mark heuristics must -give the SAME answer. - -This is the strongest available cross-check. The heuristics are wrong only -inside an overlap, where two clips' mark ranges both contain the timestamp; -everywhere else they are the behaviour that shipped and was verified on real -discs. So for a frame that falls inside exactly one clip, the two methods -disagreeing means the NEW path is wrong. - -### `a_full_pass_over_the_real_table_is_monotonic_and_totals_correctly` -Walk a whole title through the plan with provenance and assert the two -properties that decide whether a rip is watchable: output never moves -backwards for a track, and the total span matches the title's declared -duration. - -A title can have the right TOTAL duration while being wrong in the middle, -which is why monotonicity is checked per frame rather than only at the ends. - -### `one_clip_file_behind_every_play_item_is_not_distinguishable_by_byte` -A title whose PlayItems all reference ONE clip file: every span is -identical, so the tiling check's "equal to previous is allowed" arm matches -every entry and the spans are TRUSTED — while carrying no information at all -about which PlayItem a byte belongs to. - -That combination is the dangerous one: provenance looks authoritative and is -actually blind, so every frame resolves to the FIRST PlayItem and everything -past its mark range is dropped. - -### `marks_that_do_not_advance_are_placed_by_provenance` -Marks that do not advance across the title are normal — each clip file -carries its own STC — and are now PLACED, because every track carries a -source byte offset and the clip comes from that, not from the marks. - -This was refused before, which dropped exactly the branched titles the seam -plan exists for onto the inference path that cannot read them. - -### `a_restarting_clock_table_without_spans_is_still_refused` -The refusal must SURVIVE where it is actually load-bearing: a non-monotonic -table whose spans cannot be trusted has neither a usable clock nor a usable -byte offset, so there is nothing to place with and inference remains the -only safe path. - -### `seam_plan_total_matches_the_declared_duration` -This is the whole bug in one assertion: the delivered file declared 7893.385 -s and carried packets to 8029.298 s — 135.91 s of timeline the playlist says -does not exist. - -### `seam_plan_closes_the_forward_skip` -The 9.174 s forward skip between clip 2 and clip 3 must vanish. Measured in -the delivered file as a 20 s window holding 257 video packets where it -should hold 480. - -### `seam_plan_joins_an_overlap_without_rewinding` -The 1.79 s overlap at seam 1 must JOIN cleanly, not rewind the timeline. - -Clip 1's IN (6031.250 s) precedes clip 0's OUT (6033.041 s): the disc stores -that join twice. Emitting both copies is what collided in the muxer and -flattened 169 audio packets onto the 0.1 ms tick floor, putting audio ~1.8 s -ahead of picture for the rest of the film. - -### `map_under_a_seam_plan_tracks_offset_and_frontier` -`map()`'s seam-plan branch — the glue between `SeamPlan::place` and the -frontier/offset bookkeeping — was untested. Audit finding: a wrong operand in -`offset_ns = p - raw_pts_ns`, or a stale `high_ns` across a join, would -corrupt downstream cluster timing and no test would notice. - -### `a_sparse_passive_track_crosses_late` -A SPARSE passive track crosses even when its first frame after the join -lands well past the mark. - -Audit finding. A PGS subtitle track may have no event near a clip's IN at -all. Holding it to the dense-video window (250ms either side of the mark) -left it on the PREVIOUS clip's offset until its PTS finally passed that -clip's OUT — mistiming every subtitle in between by the overlap, 1.79s on -the measured title. - -### `each_track_crosses_a_join_on_its_own_frame` -This is the regression for the first attempt at this fix, which gave every -track the cursor the video had moved. At an overlap the previous clip's -audio tail is still arriving after video has crossed, and those tail frames -sit inside BOTH clips' ranges — so a shared cursor gave them the new clip's -offset, threw them forward by the overlap, and made them collide with the -new clip's own audio. Measured on a real remux: 169 audio packets flattened -onto the 0.1 ms tick floor and a 1.80 s jump, i.e. the original symptom, -still present after the timeline length was already correct. - -### `only_blu_ray_gets_a_mark_driven_plan` -Audit finding, and a regression this nearly shipped: HD-DVD `Clip` marks -come from the XPL's title-relative times, and a DVD's from cell tables — -neither is a position in the PES clock. A plan built from them is an -identity map with a drop filter: it suppresses the layer-break rebase -`adjust` performs, and drops whatever falls outside marks the PTS was never -measured against. Both formats must stay on inference. - -An earlier reading of this concluded HD-DVD was safe because its marks -happen to be contiguous, so the computed offsets were all zero. That is true -and irrelevant: the offsets were zero in the WRONG CLOCK. - -### `a_second_video_track_keeps_the_reorder_safe_window` -Audit finding: the rule was keyed on `drives`, so the EL took the branch -whose premise is "no reorder". Its ordinary reorder dip near the end of a -clip — a backward step that, during an overlap, also lands inside the next -clip's range — was then read as a join, and the EL was placed on the next -clip's offset: out of step with the base-layer frame it must be co-timed -with, by the width of the overlap. - -### `a_restarting_clock_falls_back_to_inference` -Audit finding. Each clip is validated in isolation (span > 0) but the -placement rules assume the marks are points on a single clock. Under a table -whose clips each restart their own base, a crossing can be missed — and a -missed crossing STRANDS the track on its current clip, so every later frame -falls outside that clip's marks and is dropped for the rest of the title. -Silent truncation, which is what this type exists to prevent. - -### `contiguous_clips_produce_a_constant_offset` -This is the no-regression guarantee for every title that is multi-clip but -not seamless-branching — HD-DVD's feature is chaptered this way (one real -title measured: 3 clips, each IN equal to the previous OUT). - -### `single_clip_trims_content_outside_its_marks` -A SINGLE-clip title still gets a plan: joining is not the marks' only job, -trimming to `[in, out]` is. Real discs author trailing audio (a fade after -the last video frame) PAST the OUT mark in the m2ts; the no-plan inference -path kept it, leaving audio seconds past the declared duration — the -audio-drift defect found on a real disc (audio +35.6 s past a single-clip -title's end). The plan drops it, and every KEPT frame is placed exactly as -the no-plan path would (raw rebased by `−in_ns`, which the MKV muxer already -does), so a disc with no out-of-mark content is byte-identical. - -### `single_clip_late_subtitle_does_not_inflate_offset` -PRIMARY rc3 regression: a sparse, lagging NON-VIDEO track (PGS subtitle / -trailing audio) on a SINGLE-clip title must NOT inflate `offset_ns`. This is -the exact false-positive that destroyed a real title's seek index: with a -shared frontier, a late subtitle PTS ratcheted the frontier up, then the -next normal video frame sat >3s below it and was misread as a clip -boundary, permanently bumping the offset — thousands of times, until the -Cue/cluster timestamps inflated into the billions of ms. - -Correct behaviour: non-video frames ride the current offset and NEVER touch -the frontier or the offset, so no amount of subtitle/audio lag can trigger a -rebase on a one-clip title. - -### `dv_enhancement_layer_does_not_drive_epochs` -PRIMARY rc3 regression (Dolby Vision dual-layer): a SECOND video track — the -DV enhancement layer — runs its OWN PTS timeline interleaved with the base -layer's, so the two video PTS sequences OVERLAP. The EL must be a PASSIVE -rider (drives_epoch == false): if it drove epochs, every EL GOP would look -like a multi-second backward jump against the base-layer frontier and -false-trigger a clip-boundary reset — the exact ratchet that inflated a -1-clip 1h49m timeline to ~7 h. - -### `non_video_never_advances_frontier` -Companion: a non-video frame must never ADVANCE the frontier. Even a -non-video PTS far ABOVE the current video frontier (a subtitle/audio -timestamp that leads the video momentarily) leaves `high_ns` untouched, so a -subsequent normal video frame is not misread as a boundary. - -### `continuity_large_clip_boundary_backjump_rebased` -Regression for the originally-reported band: a LARGE, real-magnitude -clip-boundary back-jump on VIDEO (clip 1 ≈ 13 min, clip 2 resets to 0) must -STILL be rebased to one continuous monotonic timeline — the genuine -multi-clip seamless behaviour is preserved, now keyed on real video -back-jumps. - -### `non_video_straggler_remapped_to_seam_at_boundary` -At a REAL video-driven boundary, a lagging NON-VIDEO tail frame from the -just-ended clip (an old-epoch raw PTS arriving interleaved after the reset) -must be REMAPPED to its true seam position with the PREVIOUS offset — not -flung ~a clip past the frontier by the freshly-bumped offset. Otherwise it -would force a forward-dated split cluster and break cluster monotonicity. - -### `normal_new_epoch_frame_leading_frontier_is_not_clamped` -Regression for the over-eager straggler clamp: a NORMAL new-epoch non-video -frame that leads the (sparse, video-only) frontier by MORE than one backstep -must ride the CURRENT offset — it must NOT be demoted into the just-ended -clip's epoch. Such a frame satisfies BOTH of the old discriminator's -conditions (current-map > frontier+backstep AND prev-map <= frontier), so -the old `prev_mapped <= high` test wrongly clamped it back ~a whole clip. The -tightened lower bound (`prev_mapped >= high - backstep`) fixes it. - -### `frames_arriving_before_their_epochs_video_ride_a_provisional_offset` -MEASURED on a real DVD title with 8 cell boundaries. The demuxer hands the -muxer ~18 audio frames of the NEXT cell before that cell's first video -frame. Riding the just-ended epoch's offset put them ~21 s in the past, and -the MKV writer's strictly-monotonic nudge then crushed the run onto one -instant 0.1 ms apart — half a second of audio as a click, eight times in an -8-minute title. - -They must instead continue after the frontier, and must rejoin the real -offset seamlessly once the video opens the epoch. - -### `a_straggler_is_judged_against_its_own_epochs_end_not_the_frontier` -MEASURED on a real HD-DVD title. Its second audio track's LAST frame carries -a clip-1 raw PTS but arrives after clip 2's video opened the epoch, so it -took clip 2's offset and landed at 12834.587 s in a 6434.100 s title — one -packet, exactly double. - -The old remap existed for this and refused it: the frame sits 23 s below the -CURRENT frontier, outside the 3 s window. Judged against the frontier of the -epoch it actually belongs to, it is 0.15 s from that epoch's end. - -### `a_saturated_frontier_does_not_overflow_on_passive_frame` (named `saturated_frontier_does_not_overflow_on_passive_frame`) -A saturated frontier must not panic the muxer. An `mkv://` source's -tick→ns multiply saturates at `i64::MAX` (mkvstream's `parse_block`), so a -hostile TimestampScale/CLUSTER_TIMESTAMP puts `high_ns` AT `i64::MAX`. Every -subsequent PASSIVE frame then evaluated `high + BACKSTEP`, which panicked -("attempt to add with overflow") out of the public `Stream::write` path in -any overflow-checked build. - -### `extreme_video_pts_does_not_overflow_the_epoch_bump` -The epoch-decision side of the same arithmetic: `adj < high - BACKSTEP` and -the `high - adj` bump both took untrusted ends. A frontier at -`i64::MIN`-adjacent values (a negative SimpleBlock-relative timestamp) and a -`i64::MAX` frontier are both reachable from container data. -</content> diff --git a/docs/nav.md b/docs/nav.md deleted file mode 100644 index a0cf169b..00000000 --- a/docs/nav.md +++ /dev/null @@ -1,79 +0,0 @@ -# dvdnav::nav — First-Play navigation executor - -The DVD arm of "mimic a real player" title selection (issue #40). - -A player enters a disc at the First-Play PGC (FP_PGC): a short program of VM -commands (parsed by `super::vmcmd`) that runs before any menu draws and -typically dispatches straight to the feature title. This module reads that -program out of `VIDEO_TS.IFO` (VMGI), executes its pre-command list on a -minimal register machine, and — when the program deterministically reaches a -title dispatch (`JumpTT`) — maps the selected VMG title through the title -search pointer table (`TT_SRPT`) to its `(VTS number, title-in-set)` -coordinates. That is the title a player would land on, so DVD title selection -can prefer it over the structural leading-cell heuristic. - -Every table count and offset is attacker-controlled, so all arithmetic is -checked and all reads are bounds-guarded — the entry point `resolve_from_vmg` -never panics on any input (exercised by the never-panic harness). - -## The `Vm` register machine - -`Vm` models the subset of the DVD-Video VM register file the First-Play -resolver needs: 16 general parameters (GPRMs) and the system parameters -(SPRMs). Execution starts from a cold machine (all zero) — the same starting -point a player has before any user interaction. The SPRMs a First-Play -routine might branch on (region, parental level, language) are -player-/session-specific and unknown at scan time; leaving them zero means -the resolver only commits to a title when the program reaches one *without* -depending on them (an unconditional dispatch, or a conditional whose -zero-register path a player also takes). When a branch genuinely depends on -an unknown SPRM the routine is treated as undecidable and the caller falls -back — see the module contract above. - -`gprm_tainted` tracks per-GPRM taint: set when the register holds a value -derived from a system parameter (SPRM). An SPRM read taints a compare -directly (`is_sprm`); this bit tracks an SPRM *laundered through* a GPRM -store so a later GPRM-only compare that reads it is still recognised as -undecidable. - -## `Vm::eval` — compare taint - -`eval` evaluates a compare predicate and returns the predicate result -together with a *taint* flag set when either operand read a system parameter -(SPRM). An SPRM's value is player-/session-specific (region, parental level, -language) and unknown at scan time, so a branch decision that depends on one -is undecidable — the caller abstains rather than commit to the -cold-power-on arm. A GPRM read taints only when that register was itself -loaded from an SPRM (tracked by `gprm_tainted`); a GPRM holding a -program-established value does not taint. - -## Test fixture: `build_vmgi` - -`build_vmgi` assembles a VMGI (`VIDEO_TS.IFO`) image with a First-Play PGC -whose pre-command list is `pre`, and a TT_SRPT at sector `tt_srpt_sector` -mapping each title to `(vtsn, vts_ttn)`. - -Layout: the fixed VMGI header (magic + the FP_PGC and TT_SRPT pointers), then -the FP_PGC (with a command table holding `pre`), then the TT_SRPT at its -sector. Kept minimal but spec-shaped so the same code path a real disc takes -is exercised. - -## Regression test rationale - -- `sprm_gated_dispatch_abstains`: a dispatch gated on an SPRM is undecidable - at scan time. `if SPRM0 == g0 -> JumpTT 1` is *true* on the cold machine - (both read 0), so without SPRM taint-tracking the resolver would wrongly - return title 1; with it, the SPRM read taints the branch and it abstains. - -- `sprm_guarded_store_then_unconditional_dispatch_resolves` (Defect-1 - regression, over-abstention): a store guarded by an SPRM-tainted compare - does NOT gate a control transfer, so it must not abstain. `if SPRM0 == g0 - -> g0 = 5` (a guarded store) is followed by an UNCONDITIONAL `JumpTT 1`; the - tainted predicate only decides whether the store runs, so the resolver must - resolve to title 1. - -- `sprm_laundered_through_gprm_abstains` (Defect-2 regression, - under-abstention): an SPRM laundered through a GPRM must still taint a - later branch. `SetGPRM g0 = SPRM20` followed by `if g0 == g1 -> JumpTT 1` — - the compare reads only GPRMs, but g0 now holds an SPRM-derived value, so - the decision is undecidable and the resolver must abstain. diff --git a/docs/network-mod.md b/docs/network-mod.md deleted file mode 100644 index 69af0cd7..00000000 --- a/docs/network-mod.md +++ /dev/null @@ -1,36 +0,0 @@ -# src/mux/network.rs — notes - -## `is_blocked_ip` / SSRF vetting rationale - -`validate_network_target` (in autorip) vets the host once at -settings-save time, but the raw hostname is re-resolved here at rip -time — a DNS-rebinding attacker can flip a previously-public name to -`127.0.0.1` / `10.x` / `169.254.x` in that window. Re-checking the -actually-resolved address at connect time closes that TOCTOU. - -`resolve_allowed_addr` resolves `addr` (host:port) and returns the -first socket address whose IP is NOT `is_blocked_ip`. It errors with -`crate::error::Error::NetworkAddrBlocked` if every resolved address is -blocked, or propagates the resolver's own error if resolution fails. -The returned `SocketAddr` carries a vetted IP literal, so the -subsequent `TcpStream::connect` cannot be re-pointed by a second DNS -lookup (it connects to the IP we vetted, not the name). - -## `accept_from_rejects_stream_without_fmkv_header` — flake fix - -FLAKE FIXED, not the behaviour under test: this test used to -`shutdown(Shutdown::Both)` the instant the bytes were written. Closing -the READ half while the server had not yet read makes the kernel answer -the server's in-flight data with an RST, so `accept_from` came back -`ConnectionReset` instead of the `InvalidInput` this asserts — rarely -when run alone, reproducibly under the loaded concurrent suite, where -the server thread is descheduled long enough for the race to open. A -logging/protocol assertion that fails at random teaches the next person -to re-run until green, which is how a real regression gets waved -through. - -Half-closing (`Shutdown::Write`) delivers the same EOF the test needs -while leaving the read half open, and blocking on a read until the -server drops its end keeps the socket alive for as long as the server -is looking at it. The port was already ephemeral (`:0`), so it was -never a port collision. diff --git a/docs/paramount.md b/docs/paramount.md deleted file mode 100644 index 051f0196..00000000 --- a/docs/paramount.md +++ /dev/null @@ -1,219 +0,0 @@ -# Paramount/onQ `playlists.xml` — derivation notes - -`/BDMV/JAR/` is application-defined space, so this format has no spec to -look up. Every field meaning below was derived by measuring real discs and -cross-checking against per-display-set content. Treat an unfamiliar value -as unknown rather than guessing — the disc's own `forced_on_flag` is the -only authoritative forced signal. - -## `ForcedSub` — the `forced_sub` CSV cell values - -The attribute reads like a boolean and was once parsed as one (`cell == -"1"` → forced). It is not. Every image in the corpus carrying this -vendor's `playlists.xml` — seven distinct discs — uses four values, and -decoding three of those discs' feature subtitle tracks and counting every -PGS display set separates them into two populations two orders of -magnitude apart: - -* `0` — a subtitle track with no forced-narrative content. On the two - discs measured that use the flag at all, not one `0` track carried a - single `forced_on_flag` display set. -* `1` — a FULL DIALOGUE track that additionally contains some - forced-narrative signs. On one measured disc, all nine `1` cells are - full tracks of 949-1411 display sets, eight of them carrying 5-14 - flagged sets and the ninth none; that disc has no dedicated forced - track at all. On another, all seven `1` cells are full tracks of - 1602-1651 display sets carrying 0-31 flagged sets. Reading `1` as - forced is what made one language present as two identical full - subtitle tracks with one of them flagged forced. -* `2` and `3` — a DEDICATED forced-narrative track. These take their own - trailing STN slots, one per localized language, duplicating a language - that already holds a full track earlier in the list. Measured: the two - `2` slots on one disc are 15 and 10 display sets, EVERY one flagged - forced, against ~1600 on that disc's full tracks; the four `3` slots on - another are 7, 14, 23 and 59 display sets against 1216-2655. What - distinguishes `2` from `3` the corpus does not reveal — both sit in the - same trailing position, both measure the same shape, and one disc uses - each for a different language — so both map alike. - -So the old reading was wrong in BOTH directions: it flagged full dialogue -tracks forced, and it discarded the cells that name the real forced -tracks. - -The `1` case is deliberately NOT carried through as a weaker "contains -forced segments" hint. There is no qualifier for that, and the asymmetry -argues against inventing one here: a wrong forced flag on a 30 MB -dialogue track is the user-visible defect, while a missing hint costs -nothing. - -Nothing downstream can undo a wrong forced label on the discs that need -it most: `mux::codec::pgs::demotable` may only clear a vendor forced -label where some track on the disc demonstrably sets `forced_on_flag`, -and measured discs using this label format never set it. - -## `MAX_COM_INDICES` — the CSV addressability bound - -The highest CSV cell position that can ever be addressed. The labelling -loops number cells 1-based into a `u16` and `break` at -`u16::try_from(i + 1)`, so cell `MAX_COM_INDICES` and everything past it -is never visited. Two different things are measured against that bound, -and they are not the same: - -* A VALUE at or beyond it cannot match any cell. This caps the set: - values are filtered before insertion, so at most `MAX_COM_INDICES` - distinct entries can ever be stored, however long the attribute is. - The `HashSet` that replaced a linear scan fixed the LOOKUP cost; this - fixes the ALLOCATION, so a disc declaring half a billion indices no - longer costs half a billion entries. -* A POSITION at or beyond it describes nothing new. This caps the WORK, - not the memory — the value filter already made the set small, but - without it every one of those half a billion cells is still split and - parsed. It is an early exit at the first position whose contents - provably cannot matter, and it is why `forced_sub` — which holds no - set at all, and so gets no protection from the value rule — is bounded - too. - -Real authoring is nowhere near either limit: the BD STN table admits at -most 32 streams per playlist, so nothing legitimate is lost. - -## `com_indices` / `forced_subs` — why they are separate functions - -Extracted so each bound is independently observable in a unit test. -Asserting the bound through `labels_from_feature` is not possible in -either case: for `com_indices`, a `HashSet` collapses repeated values and -an out-of-range index changes no label either way, so a label-level test -passes whether or not the cap exists. For `forced_subs`, the Vec is read -only as `forced.get(i)` from a loop that stops at `MAX_COM_INDICES`, so -cells past that are unreachable by construction and, again, unobservable -through the labels. Returning the raw set/Vec lets a test hand in tens of -thousands of distinct unaddressable entries and see them refused. - -## `labels_from_feature` - -Split out from `parse` so the per-type numbering and commentary/forced- -index logic is unit-testable without a `SectorSource`/`UdfFs`. - -## Test: `a_playlists_stream_list_cannot_run_into_the_next_playlist` - -Immunity pin, section-boundary half. The pixelogic parser walks a flat -string sequence and recognises its feature section's END by marker -alone, so a section with no marker behind it runs off into whatever -follows and counts it as more STN slots. Nothing here can do that: the -stream list is one attribute of one XML element, so its length is the -CSV's own cell count and its scope is the element's byte range that -`xml::find_element` returns. Text after the element — including the next -playlist's own `aud` — is not reachable from it. - -And when the boundary is MISSING the failure is closed, not open: -`xml::find_element` needs a matching close tag and yields `None` without -one, so an unterminated element ends the walk rather than swallowing the -rest of the document. - -Mutation: hand `labels_from_feature` the document instead of the -element, or let an unterminated element run to EOF → the bonus -playlist's languages join the feature's stream list. - -## Test: `bounding_the_parse_does_not_change_a_legitimate_playlist` - -`sub_com1_idx` is parsed straight out of the disc's `playlists.xml`, -which is attacker-controlled and has no length bound of its own. - -This replaces a WALL-CLOCK test. That one built a 200 000 x 1 000 001 -fixture and failed if it took over 10 s, to prove the membership test -was a set rather than a linear scan. Measured on the machine that wrote -this: 1.62 s alone, and OVER 10 s — a real failure — when the suite's -other 3 347 tests were running concurrently. A 6x margin against a -shared CPU is not a margin; it is a CI failure that looks like a flake -and gets re-run until it passes. - -It also measured the wrong thing. Making the lookup O(1) bounded the -QUERY, not the PARSE: the set was still built from every entry the disc -declared, so a hostile playlist could still force an unbounded -allocation before any lookup happened. `MAX_COM_INDICES` bounds that. - -What this test guards is that bounding did not change what a legitimate -playlist MEANS: it goes red if the bound is set too LOW (verified at 2 — -the real indices `0,2,4` stop resolving and the purposes change). It -does NOT go red if the bound is deleted entirely, because the -out-of-range filler is unobservable at the label level and a `HashSet` -collapses the repeats. Enforcement is proven separately, by -`distinct_unaddressable_indices_are_refused_not_stored`, which reads the -set itself. Two tests, two properties; neither pretends to the other's -job. - -## Test: `distinct_unaddressable_indices_are_refused_not_stored` - -The set REFUSES unaddressable indices, so a hostile playlist cannot -inflate it. DISTINCT values on purpose: a `HashSet` collapses repeats, -so a million copies of one index costs one entry and would prove -nothing. Fifty thousand distinct out-of-range indices cost fifty -thousand entries without the filter, and none with it — so this test -goes red if the bound is removed, which the label-level assertions -elsewhere cannot do. - -## Test: `forced_sub_cells_past_the_last_addressable_one_are_not_parsed` - -`forced_sub` is bounded too — the third CSV in the same function, and -the one that had no value filter to hide behind. Read through -`forced_subs` rather than through the labels for the same reason the two -tests above read the set: the subtitle loop stops at `MAX_COM_INDICES`, -so a label-level assertion cannot tell a bounded parse from an unbounded -one. - -## Test: `an_index_that_cannot_address_any_cell_is_not_retained` - -Asserted through `com_indices`, not through the labels: the labelling -loop never queries a cell position that high, so at the label level -retaining the value is unobservable and the assertion could not fail. -Reading the set is what makes the claim checkable. - -## Test: `empty_csv_slot_still_occupies_its_stn_slot` - -The `aud` / `sub` CSVs are the vendor's STN-ordered stream lists: one -slot per stream, and `aud_com1_idx` / `forced_sub` are indexed against -those same slot positions. A slot whose language cell is empty carries -nothing to label but still OCCUPIES its slot, so it must not renumber -the slots behind it. - -Numbering only the slots that carry a language collapsed every later -label one position forward per empty cell, which is how a forced marker -authored for one STN slot lands on the full-subtitle track in front of -it. - -## Test: `a_contains_forced_segments_cell_is_not_a_forced_track` - -`forced_sub` is an enumeration, and `1` is its "full dialogue track that -also carries forced signs" value — NOT "this track is forced". Measured -on a disc whose feature declares nine `1` cells among 32 subtitle slots: -all nine are full dialogue tracks of 949-1411 display sets, and the disc -has no dedicated forced track at all. Reading `1` as forced is what -produced two identical full subtitle tracks for one language with one of -them flagged forced. See "`ForcedSub` — the `forced_sub` CSV cell -values" above for the full corpus measurement, including why nothing -downstream can undo this. - -## Test: `a_dedicated_forced_narrative_cell_is_a_forced_track` - -`2` and `3` are the cells that DO name a dedicated forced-narrative -track, and the old boolean reading discarded both. Measured: these cells -occupy their own trailing STN slots, one per localized language, -duplicating a language that already holds a full track earlier in the -list. On one measured disc the four `3` slots carry 7, 14, 23 and 59 -display sets against 1216-2655 on the full tracks they duplicate — and -not one display set anywhere on that disc carries `forced_on_flag`, so -neither the scan probe nor the muxer can promote them from content. The -vendor cell is the only evidence there is. - -## Test: `an_unrecognised_forced_sub_cell_is_not_forced` - -An unrecognised cell must fall to NOT forced. Asserting forced is the -expensive mistake (a full dialogue track a player then burns on screen), -so an unknown value from a future authoring revision must not be able -to make that claim. - -## Test: `find_feature_first_wins_on_audio_count_tie` - -On a tie in audio-slot count, the FIRST playlist encountered wins -(consistent with `select_result`'s first-wins tiebreak elsewhere in the -registry) — later playlists only displace the current best on a -STRICTLY greater count. diff --git a/docs/pesbuf.md b/docs/pesbuf.md deleted file mode 100644 index c4018742..00000000 --- a/docs/pesbuf.md +++ /dev/null @@ -1,59 +0,0 @@ -# pesbuf: one accumulation buffer for parsers that assemble access units across PES packets - -A buffering parser has to answer the same question for every unit it emits: -*which PES contributed this unit's FIRST byte?* Its timestamp comes from -that PES, and so does the source byte offset that identifies which clip of a -multi-clip title the unit belongs to. The trailing PES packets that complete -the unit carry their own, later, values which must not override it. - -That question was answered three different ways. DTS kept a deque of -`(offset, pts)` markers and took the one covering offset 0 — correct. AC-3 -kept a single carry-over timestamp. TrueHD kept its own. None of them -carried the source offset at all, so provenance existed only for video, and -a title whose clip marks could not be read from timestamps alone had nine -audio and subtitle tracks with nothing to place them by. - -Three spellings of one rule is how they drifted, so this is the one place it -lives. The buffer owns the bytes AND the marks, and returns a PES's facts -together — a parser cannot take the timestamp from one PES and the source -from another, because it does not assemble them itself. - -## `PesFacts` - -Returned as a unit so a caller cannot mix fields from different packets. -The timestamps are carried RAW, as the packet had them. This type answers -*which packet* a unit's facts come from — the question that was being -answered three different ways. How a given codec derives a timestamp from -that packet stays the codec's business: DVD subtitles read `pts` only and -fall back to 0, most audio takes `pts.or(dts)`. Deriving it here would have -changed those semantics silently while fixing provenance. - -## `PesFacts::of` - -Every parser reads its frame's timestamp, source and discontinuity from -a `PesFacts` — never off a `PesPacket` field directly — so the three can -never be taken from different packets. Parsers that assemble units -across packets get theirs from `PesBuf::front`; this is the same value -for a parser whose unit begins in the packet it is handed. - -## `PesFacts::presentation_ns` - -PTS and DTS are not two spellings of one value: PTS is when to display, -DTS is when to decode, and for a stream that REORDERS (video carrying -B-frames) they differ, so reading DTS as a presentation time would be -wrong. Reordering is handled by the video path, which reconstructs -display order rather than calling this. - -For everything that reaches here — audio and subtitles — there is no -reordering, so DTS *is* the presentation time, and falling back to it is -reading the same value from whichever field the packet used. A fallback -for a missing field, not a second rule: dvdsub read `pts` alone and -returned 0 for a packet that carried only DTS. - -## `PesBuf::drain` - -The mark covering the new front byte is RETAINED at offset 0 even though -its packet started earlier: those bytes are still that packet's. Dropping -it would attribute the remainder of a straddling unit to whichever packet -happened to start next — precisely the misattribution this type exists to -prevent, and it would land at a clip boundary, the one place it matters. diff --git a/docs/pgs-forced-probe.md b/docs/pgs-forced-probe.md deleted file mode 100644 index 290199f1..00000000 --- a/docs/pgs-forced-probe.md +++ /dev/null @@ -1,452 +0,0 @@ -# Content-based forced-subtitle detection for PGS tracks - -## Module overview - -`freemkv info` and the muxer must agree on which subtitle tracks are forced. -The muxer derives it from the PGS `forced_on_flag` while muxing a rip; this -module gives `info` the SAME verdict up front by reading the title's PGS -streams and feeding them through the one shared classifier -(`crate::mux::codec::pgs::ForcedTracker`) — so the two never diverge. - -WHERE it reads is the whole design. A track is forced iff EVERY display set -carries `forced_on_flag`, so one non-forced set disproves forced for good, -while proving forced needs the whole track — and the tracks that are -expensive to prove are the cheap ones to read (a forced-narrative track is -tens of display sets; a full dialogue track is thousands). A bounded budget -spent on the title's HEAD therefore learns nothing at all: a feature's -subtitles begin minutes in, past the end of any affordable prefix. The budget -(`PROBE_BUDGET_SECTORS`) is instead SPREAD over each extent as sample windows -(`plan_windows`), so every window is an independent chance to catch a display -set. - -Cost: the budget is a hard ceiling per call. A run stops early once no track -can still change its outcome — disproven, and with no wrong forced label left -to correct. - -Contradicting a label: content may CLEAR a vendor forced flag as well as set -one, but only behind `crate::mux::codec::pgs::demotable` — an absence of -`forced_on_flag` proves nothing on a disc whose authoring never sets it. - -Encrypted content: the probe reuses whatever `SectorSource` the scan holds. -With a decrypting source it sees real PGS; without keys it reads ciphertext -and observes no display sets, in which case it leaves each track's existing -(vendor-label-derived) forced flag untouched rather than asserting anything. - -Truncated reads: the probe is best-effort, but "best-effort" must not mean -"assert a verdict from an arbitrary prefix". `StopReason` records why the -read loop ended and narrows what may be asserted accordingly. - -## `CHUNK_SECTORS` (read chunk size) - -A whole number of AACS aligned units (3 sectors / 6144 B), because with a -decrypting source — the case this module's doc promises — every read must -begin on a unit boundary measured from the extent base or -`DecryptingSectorSource` rejects it outright with `DecryptFailed`. At 1024 -(`1024 % 3 == 1`) every chunk after the first drifted off the boundary, so -content-based forced detection was unreachable past the first chunk of an -AACS disc. 1023 = 341 units. - -## `STALL_RETRY_LIMIT` - -How many times a read that came back with less than one AACS aligned unit — -so the read position could not advance without leaving the unit grid — is -retried at the same LBA before the run is declared truncated. A couple of -retries covers a source whose batching straddles the request (a short call -followed by a satisfying one); a source that can never yield a whole unit -must not spin, so the count is small and the stop is `ReadFailed` -(inconclusive, not memoised). - -## `PROBE_BUDGET_SECTORS` - -The probe's natural exit is "every track has shown a non-forced display -set", which a genuinely FORCED track never satisfies — so on the common -authoring (a forced-narrative track for foreign dialogue) the loop would -otherwise read the title's whole extent set, tens of GB, at optical-drive -speed. - -UNCHANGED from the head-first design this replaced: the same 256 MiB buys a -completely different observation now that it is SPREAD (see `plan_windows`) -instead of spent on the title's first 27 seconds. - -## `PROMOTE_MIN_DISPLAY_SETS` - -How many display sets a SAMPLED run must have seen on a track before "all of -them were forced" may be asserted as a verdict. - -A sampled run sees a fraction of a track, so a forced verdict from it is an -absence claim over that fraction. One display set is not an observation of a -track: on a track that flags some of its sets and not others — measured, -such tracks exist, flagging a quarter of their display sets — catching a -single flagged set and calling the track forced is a user-visible mistake (a -full dialogue track that players then force on screen). Two is a low bar, -but it removes the single-hit case that dominates the risk. A run that read -every extent end to end is not sampling and is not subject to this. - -## `WINDOW_SECTORS` - -Sized against MEASURED subtitle density, not guessed: across a sample of -feature titles a full dialogue track carries a display set every ~30-50 MB -of clip, so a 32 MiB window is about even money on its own and the plan's -eight of them make an observation near-certain. Halving the window and -doubling the count buys the same expected number of observations for the -same bytes — but twice the seeks, and a measured 6x wall-clock penalty on a -source whose read batching collapses after every jump. Fewer, longer -windows. - -## `MIN_WINDOW_SECTORS` - -A window smaller than this is too short to be likely to contain a display -set at all, so it would spend drive time to learn nothing; a title -fragmented into so many extents that its per-extent share falls below the -floor instead samples fewer extents (the global budget stops the run) rather -than sampling all of them uselessly. - -## `MAX_WINDOWS_PER_EXTENT` - -Past this, extra windows buy no extra expected observations for the same -bytes (the expectation depends on total bytes read, not on how they are cut -up) and cost another seek each. - -## `SampleWindow` - -`offset` sectors from the extent's `start_lba`, `len` sectors long. Both are -whole numbers of AACS aligned units, so every read inside the window stays -on the unit grid the decrypting source demands. - -## `plan_windows` - -The core of the redesign. The forced predicate is ASYMMETRIC: a track is -forced iff EVERY display set carries `forced_on_flag`, so - - * ONE non-forced display set DISPROVES forced, permanently — and the - tracks that need disproving are the big ones (full dialogue tracks: - measured shape, one to two thousand display sets spread over the whole - feature); - * PROVING forced needs to see the whole track — but a genuine forced - track is tiny (measured shape: tens of display sets, well under a - megabyte). - -So the expensive-to-prove case is the cheap-to-read one, and the case that -dominates the budget is disproved by a single hit anywhere in the title. A -head-first prefix is therefore the worst possible allocation: it reads the -start of everything, where a feature has no subtitles at all (measured: the -first display set lands well past the first 256 MiB), so it disproves -nothing and observes nothing. Spreading the SAME budget over the extent gives -every window an independent chance of landing on a display set. - -The plan is a pure function of `(sector_count, share)` — no clock, no -randomness, no dependence on what has been read so far — so two runs over -the same extent with the same share read the same bytes, which is what makes -the per-extent memo (see `ForcedProbeCache`) reproducible rather than a -snapshot of one run's timing. - -## `TrackEvidence` - -The two monotone facts a `ForcedTracker` accumulates about one probed -extent, and nothing else. - -Keeping the EVIDENCE (rather than a composed forced/not-forced verdict) is -what makes per-extent memoisation sound: both `non_forced`/`forced_seen` -fields only ever go from `false` to `true` as more data is seen, so a -title's verdict is the field-wise OR over its extents, in any order, with no -dependence on how the extents were grouped into playlists. - -## `CachedEvidence` - -One extent's memoised evidence for one track, WITH the coverage it rests on. - -The coverage is the whole point. Evidence from a sampled (or budget-cut) -read describes the sectors that were actually fed to the demuxer, not the -extent — memoising it under the extent's full key and replaying it to -another playlist asserts a completeness the read never had. Recording -`covered` alongside the evidence's own `sampled` flag lets a later run -decide whether the entry answers ITS question or whether the extent has to -be read again. - -`CachedEvidence::answers`: `non_forced` is POSITIVE evidence (a non-forced -display set was seen on the wire) and settles the track outright, so its -coverage is irrelevant. Everything else is an ABSENCE claim, and an absence -is only worth what was looked at: honour it only when at least as much of -the extent was covered as this run intended to cover. - -## `ForcedProbeCache` - -Memoises probe results across titles, keyed PER PHYSICAL EXTENT and per PGS -track — `(start_lba, sector_count, pid)`. - -Many playlists on one disc reference the same clips (main feature, play-all, -seamless-branch variants) but rarely with byte-identical extent LISTS: 00800 -= `[A, B]`, 00801 = `[A]`, 00802 = `[B]` are three different lists over two -clips. Keying on the whole list de-duplicated only exactly-identical -playlists and re-read every shared clip once per list — up to -`PROBE_BUDGET_SECTORS` (256 MiB) of optical-drive time each. Per-extent -keying reads each physical extent at most once per disc, and per-track -keying means a playlist that declares MORE PGS tracks over the same extents -still probes the extra ones instead of silently taking a verdict map that -has no entry for them. - -Only extents whose read reached a DESIGNED stop are memoised (see -`probe_and_set_forced`), so one cancellation or read fault is never frozen -in as an extent's answer — and each entry carries the COVERAGE behind it -(see `CachedEvidence`), so a sampled observation is never replayed as if the -whole extent had been read. - -## `StopReason` - -Why the read loop stopped — which decides whether the observations it -accumulated may be applied as an authoritative verdict. - -The distinction matters because the two kinds of per-track verdict rest on -opposite kinds of evidence: - - * "not forced" is POSITIVE evidence — a non-forced display set was - actually seen on the wire. Nothing read later can retract it, so it is - sound no matter how the loop stopped. - * "forced" is an ABSENCE claim — display sets were seen and none of them - was non-forced. It is only sound if the read got far enough for that - absence to mean something. On an arbitrarily truncated prefix it does - not. - -`Budget`: a DESIGNED stop, not a failure — the natural exit never fires for -a genuinely forced track, so the budget exists precisely so that a forced -verdict can be accepted from a bounded prefix. A forced track's display -sets appear throughout the title, so the prefix is representative — treating -this as inconclusive would disable forced detection outright, the very -thing the budget was added to enable. - -`Halted`: operator cancellation. The bytes read were read correctly, but the -cut-off point is arbitrary — cancellation can land after a single chunk (or, -as with an already-cancelled halt, after none at all). Epistemically that is -the same arbitrary prefix as a read fault, so an absence claim from it is -not trustworthy. - -## `verdicts` - -Compose the per-track verdicts a run is ENTITLED to assert from the -evidence it gathered. A track absent from the result keeps its -vendor-derived flag. - -Four gates, all PER TRACK, because the evidence is per track: - * `observed` — saw no display set at all, so nothing is known. (Never - assert "not forced" from having seen nothing.) - * on a truncated run, `non_forced` — the track saw an actual non-forced - display set, which no further reading could retract, so that verdict - stands even though the run was cut short. A track that merely hadn't - YET seen a non-forced set is exactly the claim the truncation - invalidates, so it is dropped and keeps the vendor flag. - * for the NOT-FORCED verdict, `crate::mux::codec::pgs::demotable` — the - verdict may be contradicting a vendor label, and "no display set - carried `forced_on_flag`" says nothing at all on a disc whose authoring - never sets that flag. Discs like that exist (measured: not one track on - the disc carries it), and without this gate the probe would strip the - correct forced label off every track on every one of them. - * for the FORCED verdict, a minimum display-set count - (`PROMOTE_MIN_DISPLAY_SETS`) unless the read was complete — over a - SAMPLE of a mixed track, one flagged set alone is not enough to promote - the whole track to forced. - -## Tests: fixtures and mutation-guard rationale - -`TsReader` (fixed BD-TS byte stream, served once then EOF): sector-granular, -like every real `SectorSource` — a read served from the payload's short tail -zero-pads to the sector boundary and reports whole sectors. (The probe -accounts in SECTORS, so a source that returned a sub-sector byte count could -never advance.) - -`ts_stream`: the follower carries the SAME display set deliberately. It used -to be a hardcoded NON-forced one, which was invisible only because the probe -threw the last PES of a run away; now that the run's tail is drained, a -contradicting filler would smuggle an observation the fixture never meant to -make. - -`PartialTsReader`: serves a fixed BD-TS byte stream, then either fails or -runs on with zeros. Models the two truncated-run shapes: real content -observed, then the read abandoned mid-title, versus real content observed -and then a designed stop at the budget. - -`overlapping_extent_lists_read_each_clip_once`: MEASURED — overlapping-but- -not-identical extent lists must not re-read the shared clips. A disc's -playlists share clips without sharing whole extent LISTS (00800 = [X, Y], -00801 = [X], 00802 = [Y]), and keying the cache on the whole list -de-duplicated only exactly-identical playlists: each of the three lists -missed, so clip X was read twice and Y twice — up to `PROBE_BUDGET_SECTORS` -(256 MiB) of optical-drive time per miss. - -`extra_pgs_track_over_known_extents_is_still_probed`: a later playlist that -declares MORE PGS tracks over the SAME extents must still probe the extra -track. With the cache keyed on the extent list alone, the verdict map it hit -had no entry for the new PID, so that track was never probed and silently -kept its vendor-label flag — `info` then reported a different forced flag -for it depending purely on playlist ordering. - -`probe_reads_stay_on_aacs_unit_boundaries`: every probe read must begin on -an AACS aligned-unit boundary measured from the extent's own base, and the -probe must declare that base to the source. A `DecryptingSectorSource` -holding AACS keys rejects any other read outright (`DecryptFailed`) — and -with a 1024-sector chunk (`1024 % 3 == 1`) every read after the first was -misaligned, so content-based forced detection was unreachable past the first -chunk of an encrypted disc, silently. - -`short_reads_do_not_skip_sectors`: a short-but-nonzero read must advance by -what was READ, not by what was requested. Advancing by the request skipped -the unread tail of every chunk — silently, with `StopReason` still -`Exhausted`, so the absence-based forced verdict was asserted (and -memoised) over sectors nobody read. - -`short_reads_stay_on_aacs_unit_boundaries`: a short read must not break the -aligned-unit invariant the chunk size exists to hold. `CHUNK_SECTORS` is a -multiple of `ALIGNED_UNIT_SECTORS` so that every read BEGINS on an AACS -aligned-unit boundary measured from the extent base; a source that serves -fewer sectors than requested (a 64-sector prefetch batch: `64 % 3 == 1`) -used to advance `lba` by that raw count, so every subsequent read of the -extent was off the unit grid. `DecryptingSectorSource` rejects those before -reading (`DecryptFailed`) → `ReadFailed` → `absence_is_conclusive()` false → -no verdict asserted and nothing memoised, i.e. content-based forced -detection silently degraded to the vendor label on precisely the encrypted -discs it was fixed for. - -`a_source_below_one_aligned_unit_stops_instead_of_spinning`: a source that -can never yield a whole aligned unit cannot be advanced past without -leaving the unit grid — so the loop retries a bounded number of times and -then stops. It must NOT spin: the test simply completing is the assertion, -plus a bounded read count and an inconclusive (uncached, -vendor-flag-preserving) outcome. - -`non_pgs_subtitle_codec_is_excluded_from_the_probe`: mutation guard for the -`sub.codec == Codec::Pgs` match guard (probe's PID collection) — only PGS -subtitle tracks are ever probed by content; DVD VobSub forced comes from the -IFO/vendor path, never from sniffing PGS segments over non-PGS bytes. If the -guard were dropped, a non-PGS subtitle stream would be treated as a PGS PID -and the reader would be touched even though there is nothing PGS to probe. - -`stalled_retries_stop_at_exactly_the_limit`: mutation guard for `stalled > -STALL_RETRY_LIMIT` — exactly `STALL_RETRY_LIMIT` retries are allowed -(`STALL_RETRY_LIMIT + 1` total read attempts) before the stalled run gives -up. Weakening the comparison to `==` or `>=` still stops the spin (so a -`<=` bound alone does not catch it), but one retry early — after -`STALL_RETRY_LIMIT` attempts instead of `STALL_RETRY_LIMIT + 1`. - -`filler_packets`: `STALL_RETRY_LIMIT` padding TS packets (sync byte only, -PID 0 → `adaptation == 0` → discarded harmlessly by the demuxer) so the real -display set lands at a byte offset that survives a correct `got * -SECTOR_BYTES` feed length but is cut off by a mutated `got + SECTOR_BYTES`. - -`feed_uses_the_full_read_length_not_a_truncated_one`: mutation guard for -`got as usize * SECTOR_BYTES` (the feed-length computation on a fully-served -chunk) — a 3-sector read must hand the WHOLE 6144-byte chunk to the -demuxer. Padding pushes the real display set to byte 4032 — past `got + -SECTOR_BYTES` (2051) but inside `got * SECTOR_BYTES` (6144) — so a mutated -addition would silently drop it from the feed and the run would never -observe it. - -`RealThenZerosReader`: like `PartialTsReader`'s `ThenWhat::Zeros`, but also -counts every sector requested (not just what one extent's read attempted), -so a test can measure how much of a SECOND, effectively infinite extent -actually got read. - -`carried_non_forced_evidence_stops_reading_a_content_free_extent`: mutation -guard for the `||` in the early-exit check ("every track has already shown -a non-forced set — counting evidence CARRIED IN from other extents") — -non-forced evidence carried in from a prior extent must stop reading a later -extent immediately, even though that later extent's OWN fresh tracker has -not itself observed anything. Weakening `||` to `&&` requires local -confirmation too, so a huge trailing extent with no PGS content of its own -would be read all the way to the sector budget instead of one chunk. - -`TrackShape`: `first_sector` and `period_sectors` must be multiples of -`ALIGNED_UNIT_SECTORS` — a BD-TS packet is 192 bytes and the demuxer works -on that grid from the start of each feed, so only every third sector (3 * -2048 = 32 * 192) begins on the grid. - -`SyntheticClipReader`: a feature-length clip — zeros everywhere except -where a `TrackShape` puts a display set. Serves any LBA asked for (unlike -`TsReader`, which must be read in order) — which is exactly what a sampling -probe has to be tested against. - -`a_demoted_track_stops_calling_itself_forced`: spec — a verdict that -DEMOTES a track clears a `Forced` qualifier with it. The two fields are one -fact rendered for two consumers — the muxer writes Matroska `FlagForced` -from `forced`, the JSON metadata sidecar writes its qualifier string from -`qualifier` — so leaving the qualifier behind publishes a track that calls -itself forced next to a header that says it is not. Mutation: delete the -qualifier assignment in `apply_verdicts` — the track comes out `forced == -false, qualifier == Forced`. - -`a_demoted_track_keeps_a_qualifier_that_is_not_a_forced_claim`: spec — a -qualifier that is not a forced claim is not the probe's to touch. `Sdh` -says something about the track's content that a forced-narrative verdict -neither confirms nor refutes. Mutation: clear the qualifier unconditionally -on demotion — the SDH marking is lost. - -`subtitles_beyond_the_old_head_budget_are_observed`: THE headline fix. A -feature's subtitles do not start at the top of the title — measured, they -begin well past the first 256 MiB — so a head-first budget read the opening -minute of black and logos, observed NOTHING, and contributed nothing to the -verdict on any disc long enough to matter. Spreading the SAME budget over -the extent puts windows where the subtitles are. - -`the_budget_is_spread_across_the_extent`: the allocation, not just the -total — the budget must be spread across the extent instead of poured into -its head. The track here is forced throughout, so it never settles and the -run spends its whole budget — which is precisely the run whose ALLOCATION -matters. (A track disproven by its first display set stops the run early, -by design; that is the per-track exit, tested separately.) - -`a_thin_sample_is_not_replayed_to_a_playlist_that_would_read_more`: the -memoisation hazard. A sampled read covers a FRACTION of an extent, so its -evidence is a statement about that fraction — but it used to be filed under -the extent's full key and replayed to every other playlist sharing the -clip, including playlists that would have read far more of it. An absence -claim ("no non-forced display set here") inherited that way asserts a -completeness the read never had. - -`a_disc_that_never_sets_the_forced_flag_cannot_demote_anything`: the case -the guard exists for. On a disc whose authoring never sets `forced_on_flag` -— measured: not one track on the disc carries it — the absence of the flag -says nothing whatsoever about any track. Demoting on it would strip the -correct forced label off every forced track on every such disc. - -`a_forced_shaped_track_keeps_its_label_on_a_disc_that_uses_the_flag`: the -other side of the guard — a track with the SHAPE of a forced track -(measured: tens of display sets against a full track's thousands) keeps its -label even on a disc that uses the flag. The flag being in use elsewhere -does not oblige every forced track to carry it. - -`cached_evidence_answers_only_what_its_coverage_supports`: coverage is what -makes a memo replayable. An entry from a thin sample must not answer a -question that needs a thorough one — but positive evidence (a non-forced -display set was SEEN) settles the track whatever the coverage, and a -complete read answers everything. - -`re_reading_one_extent_merges_its_memo_instead_of_doubling_it`: a playlist -may list the SAME clip twice. The second read must merge into the extent's -memo, not double it — `displays` feeds the demotion shape test, and -counting the same display sets twice would inflate a track towards being -demotable on evidence that was only read once. - -`the_last_display_set_of_a_run_is_not_thrown_away`: the tail of a sampled -run must not be discarded. The demuxer holds the last PES of a run open -waiting for the next PUSI, which — with sampling — lies in another window -or nowhere at all. Unflushed, that is one lost display set per window, -worst at exactly the places the sample is thinnest. - -`a_single_window_sample_is_taken_from_the_middle_of_the_extent`: a title -cut into many clips gives each extent a single window's worth of budget. -That window must not sit at the extent's head — sampled at the head, every -clip is read at the same relative position, and for the first clip that -position is the opening of the feature: the one stretch that reliably has -no subtitles in it, which is the whole defect being fixed. - -`one_display_set_in_a_sampled_run_does_not_prove_a_track_forced`: promotion -is an absence claim too, and a SAMPLE cannot support it off a single display -set. Measured: tracks exist that carry `forced_on_flag` on a quarter of -their display sets and not on the rest — catch one of the flagged ones and -nothing else, and a full dialogue track gets flagged forced, which players -then force on screen. - -`a_complete_read_may_still_promote_from_one_display_set`: a run that read -every extent end to end has no unread gap to hide a non-forced set in, so a -genuine single-sign forced track (measured: they exist, one display set for -the whole feature) is still promoted. Pinned by -`the_last_display_set_of_a_run_is_not_thrown_away`, which is exactly that -case; this asserts the two rules do not collide. diff --git a/docs/pgs.md b/docs/pgs.md deleted file mode 100644 index 5de66c10..00000000 --- a/docs/pgs.md +++ /dev/null @@ -1,131 +0,0 @@ -# HDMV PGS subtitle parser (`src/mux/codec/pgs.rs`) - -## Display and clear are separate decoder events - -A visible composition (PCS with objects) starts a subtitle. An empty -composition (PCS with zero objects, followed by its remaining segments and -END) clears it. Both sets must survive ripping. The parser also computes -`BlockDuration = clear_pts - display_pts` for the visible set, retaining the -existing fallback duration when the actual end is unavailable. - -Previously, the parser discarded the empty PCS and relied solely on -`BlockDuration`. FFmpeg's PGS decoder sets `end_display_time` to `UINT32_MAX` -and expects another composition to replace or clear it. The generic decoder -only uses packet duration when `end_display_time` is zero. A player using -that decoded lifetime can therefore leave a subtitle visible until the next -one, however large the gap. See the [PGS decoder][pgsdec] and -[generic subtitle decoder][decode]. - -Empty compositions are now accumulated with their WDS/END continuation -packets, retaining the opening PCS's timestamp and source position. A -complete clear is emitted at END without waiting for the next subtitle. -Its duration is zero (the MKV writer rounds this to one timestamp tick); -it changes decoder state immediately and has no visible bitmap to expire. -Clear sets do not count toward forced-track classification. - -### Relationship to freemkv/freemkv#52 - -[#52] reports missing timestamps during FFmpeg transcoding with subtitle -stream copy. Adding a duration to every block does not, by itself, guarantee -valid packet timestamps after bitstream filtering. FFmpeg's Matroska muxer -automatically applies [`pgs_frame_merge`][merge]. When it merges separate -WDS and END packets, it copies packet properties from the PCS-containing -packet. Dropping that PCS leaves an orphaned set whose merged packet can -have no timestamp even though each input MKV block had one. - -Running the new FFmpeg tests against the old parser reproduces both reported -warnings (unset timestamps and fabricated PTS), and decodes the supposed -clear events as visible compositions. Both tests pass with the fixed parser. -Preserving the complete clear set addresses that mechanism as well as the -lingering bitmap. This does not establish that every playback/transcode -problem in #52 has the same cause; verification against the reporter's -source is still needed. - -### Regression coverage - -`src/mux/codec/pgs_tests.rs` builds decoder-valid synthetic PGS sets with a -2x2 bitmap; no movie/disc assets are needed. Coverage includes an hour-long -gap between forced subtitles, split/timestamp-less continuations, leading -and repeated clears, byte preservation, provenance, incomplete sets, -forced-track classification, and a parser → MKV → reader round trip. -The `.sup` writer tests also cover original clear precedence and fallback -clears for older MKVs carrying only durations. - -Two additional Rust tests invoke `ffprobe`/`ffmpeg`: one checks decoded -empty compositions at the actual clear times, the other stream-copies the -MKV and checks warnings, timestamps, corruption flags and decoded clears. -These are explicitly ignored in normal local runs because they require -external binaries. The QA workflow installs FFmpeg on Linux and runs them -against the release build; dev CI runs the pure Rust coverage: - -```sh -cargo test --lib pgs -cargo test --release --lib mux::codec::pgs::lifecycle_tests::ffmpeg_ -- --ignored -``` - -[pgsdec]: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/pgssubdec.c -[decode]: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/decode.c -[merge]: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/bsf/pgs_frame_merge.c -[#52]: https://github.com/freemkv/freemkv/issues/52 - -## `display_set_is_forced` - -The mux uses this to detect a *forced-narrative track* (every displayed -subtitle forced) without relying on the disc's vendor label metadata, so -forced subs are flagged `FlagForced` even on discs that carry no such blob. - -## `demotable` - -Full rationale and measured track shapes: - -* Promotion (0 → 1) needs no gate: it rests on positive evidence (every - display set carried `forced_on_flag`). Demotion rests on an ABSENCE, and an - absence is only meaningful if the flag is in use at all. Measured: discs - exist on which NO track carries `forced_on_flag`; there, "this track has no - forced display sets" is a fact about the authoring house, not about the - track, and demoting on it would strip a correct forced label from every - track on the disc. -* `DEMOTE_MIN_DISPLAY_SETS`: a track must have shown at least this many - display sets before "none of them was forced" is allowed to contradict a - vendor forced label. Absence is weak evidence on a handful of sets: a - genuine forced-narrative track is SMALL (measured shape: tens of display - sets for a whole feature), so a couple of unflagged sets is exactly what - one looks like on a disc whose authoring never sets the flag. -* `DEMOTE_MIN_DISPLAY_SHARE_DIVISOR`: the track must also carry at least this - fraction (1/N) of the display sets of the busiest subtitle track on the - disc. Measured: a dedicated forced track carries a low-tens count of - display sets for a whole feature, a full dialogue track carries one to two - thousand — two orders of magnitude apart. A track sitting within a quarter - of the busiest track's count is a full track, whatever its label says; a - track at one percent of it is the forced-narrative track its label claims - and must keep that label. -* A track that itself mixes forced and non-forced display sets is the - stronger form of "flag in use": it shows the authoring house making that - distinction deliberately. The shape test still applies to it — a SMALL - track with a couple of flagged sets is a forced track whose authoring - flagged some of its signs, and demoting it would be exactly the mistake the - shape test exists to prevent. - -See also `docs/pgs-forced-probe.md` for the `info`-time probe that reuses -this same classifier. - -## Test notes - -`observed_stays_false_until_a_real_display_set_is_seen`: `observed()` is the -probe's "did I actually see any PGS content?" signal. When false the track's -forced state is UNKNOWN and the probe leaves whatever flag the disc's own -metadata supplied alone; when true, the probe overwrites that flag with its -own verdict. A tracker that always claims to have observed something would -let an unread or undecrypted subtitle track — where `is_forced()` is -vacuously false — overwrite a correct vendor "forced" flag with "not forced". - -`a_mixed_track_corroborates_the_flag_itself`: a track that itself mixes -forced and non-forced display sets needs no corroboration from a sibling — -the flag is demonstrably in use ON THIS TRACK. Models a busy track labelled -forced that flags one or two of its hundred-odd display sets. - -`a_lone_segment_emitted_directly_still_carries_provenance`: a lone non-PCS -segment with a PTS is emitted straight through rather than accumulated, and -it must still carry provenance. This path was missed on the first pass and -showed up on a real disc as a subtitle track with no source offset — the one -track out of forty that could not be placed by byte. diff --git a/docs/pipeline.md b/docs/pipeline.md deleted file mode 100644 index 09d0ccdf..00000000 --- a/docs/pipeline.md +++ /dev/null @@ -1,228 +0,0 @@ -# `src/io/pipeline.rs` — bounded producer/consumer pipeline - -The file-backed mux highway is built on this primitive. (An earlier sweep -sink also used it; sweep moved to freemkv-engine in 1.6.0.) - -## Cancellation and error semantics - -- Producer dropping the channel (via `Pipeline::finish` dropping `tx`) - signals end-of-stream; consumer flushes via `close()` and returns its - `Output`. -- Consumer returning [`Flow::Stop`] also calls `close()` and returns its - `Output`. `send()` from the producer will then either succeed (if the - item already fit in the channel buffer) or fail with `Err(item)` once - the consumer has dropped its receiver. -- Consumer returning `Err` from `apply` skips `close()` entirely; the - consumer keeps draining the channel so the producer never blocks on a - dead receiver, and the first error is propagated as the `JoinHandle` - result. -- Consumer panic is converted into [`Error::PipelineConsumerPanicked`] - (the panic message is logged, not embedded in the error value). - -## Debug logging - -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. - -## `JOIN_TIMEOUT_SECS` / `FINISH_GRACE_SECS` - -`JOIN_TIMEOUT_SECS` (10 minutes) is a backstop, not a normal timeout — -the consumer is expected to drain in seconds. If it fires, something is -wedged inside a kernel call the consumer thread can't unwind from. It is -deliberately long so a consuming application's own (shorter) stall -watchdog gets the first chance to escalate; this join only fires when no -such watchdog intervenes. - -`FINISH_GRACE_SECS` is the short grace period spun after a halt or the -10-minute timeout fires in `Pipeline::finish_with_halt`. Most wedged -consumers that are "about to return" when the halt fires will unblock -within a few seconds (e.g. their bounded_syscall timeout returns and the -consumer drains). Spinning here converts those into clean joins and -releases the output file handle, at the cost of at most this much extra -latency on a genuinely stuck consumer before the leak is accepted. - -## `SEND_HALT_CHECK_INTERVAL` - -Halt-check cadence for the send loop. Producer blocks on -`crossbeam_channel::Sender::send_timeout` for this slice — the kernel -wakes it the instant the consumer drains a slot, so on the happy path -there's no throughput cap from this primitive at all (the cap is -whatever the underlying medium can sustain). When the consumer is -genuinely wedged, the timeout fires every `crate::halt::POLL_INTERVAL` -and the producer checks the halt token; that's the latency a stop -request will observe. Single source of truth for the interval lives in -`crate::halt::POLL_INTERVAL` (also used by `bounded_syscall`); aliased -here for readability of the send/finish call sites. - -0.21.7 replaced an old `std::sync::mpsc::sync_channel` + 50 ms -`thread::sleep` polling loop that capped mux throughput at ~20 -frames/sec ≈ 1 MB/s on saturated channels. - -## `consumer_panicked` - -Turns a consumer-thread panic payload into the numeric -`Error::PipelineConsumerPanicked` variant. The original panic message -(the two stdlib formats `panic!` produces: `&str` / `String`) is logged -at the join site for diagnostics — it is NOT baked into the error value, -since the library carries no English text in its errors. Callers -discriminate on the variant. - -## `state` module - -Consumer lifecycle state, shared between the caller and the consumer -thread. A plain `AtomicBool` could not make "the caller abandons" and -"the consumer commits to finalising" mutually exclusive: the consumer -loaded the flag, the caller stored it, and the consumer then finalised -the container anyway — the caller reporting the rip as interrupted while -a fully finalised MKV (Cues written, Segment size patched) landed on -disk, indistinguishable from a complete one. The two transitions are -therefore a single compare-exchange each, out of `state::RUNNING`: -whoever wins decides, and the loser observes the winner. - -## `finish_with_grace` - -After a halt or deadline fires, spin-polls `handle.is_finished()` for -`grace` before accepting the thread leak. This converts the common -"nearly-done" consumer (whose own bounded_syscall just returned and is -about to drop its output file) into a clean join, releasing the file -handle without waiting the full grace period. - -If the consumer is still running when the grace expires, the `abandoned` -flag is set and the `JoinHandle` is dropped, detaching from the thread. -The leaked consumer keeps running until its current kernel call returns, -then — observing `abandoned` — exits WITHOUT calling `close()`, so it -does not finalise an output the caller has already reported as failed. -It does not unblock the in-flight syscall itself; that still returns on -its own (or at process exit). - -## `Pipeline::spawn` naming history - -This module's only in-crate `spawn_named` caller is the mux driver, -which names its thread `freemkv-mux-consumer`; `Pipeline::spawn` (the -default name) is used only by this module's unit tests. Earlier -revisions of this doc twice named a caller that had left the crate -(`disc::patch`, then Sweep's `freemkv-sweep-consumer` thread); both went -to freemkv-engine with the recovery passes in 1.6.0, and each in turn -sent readers hunting a component that is not here. Name callers that -live in THIS crate, or none. - -## `Pipeline::send` vs `send_with_halt` - -`send_with_halt` is NOT a `foo_with_X` variant of `send`, despite the -name — the two encode OPPOSITE policies on the same event, each with its -own test: after the consumer's `apply` has failed, `send` still succeeds -(the consumer keeps draining, so the channel accepts the item), while -`send_with_halt` hands the item straight back — so a producer does not -read an hour of disc for a write that died on the first frame. -Collapsing them into one Option-parameterised method deletes one of -those behaviours; it was tried and `apply_error_drains_then_propagates` -caught it. - -## `Pipeline::finish` vs `finish_with_halt` - -Likewise not a `foo_with_X` variant: `finish` joins and waits however -long the consumer needs, while `finish_with_halt` gives up after -`JOIN_TIMEOUT_SECS` and reports halted. Which is right depends on -whether the caller has a user waiting to cancel — the mux driver does -and uses `finish_with_halt`; the unit tests do not and use the plain -join. Merging them means picking one of those policies for both. - -## Test notes - -- `empty_pipeline_still_calls_close`: zero items sent, closing the - pipeline immediately must still call `close()` exactly once and - return its `Output`. The consumer loop's `while let Ok = rx.recv()` - exits on the dropped `tx` with zero iterations, then runs - `sink.close()`. Mutation: moving `close()` inside the loop would - never call it here. -- `close_error_propagates_from_finish`: `close()` returning `Err` must - surface that error from `finish`, not be swallowed — a clean producer - drop still flushes via `close()` and an `Err` `Output` is a valid - return. Mutation: if the consumer ignored `close()`'s `Result` and - returned `Ok`, this fails. -- `try_send_reports_full_when_saturated`: `try_send` must report `Full` - when the channel is saturated and the consumer is wedged, NOT block. - Wedges the consumer on the first item (depth=1), fills the one buffer - slot, then asserts `try_send` returns `Full` immediately. Mutation: - routing `try_send` to the blocking `send` would hang. -- `try_send_reports_disconnected_after_consumer_gone`: `try_send` must - report `Disconnected` once the consumer thread has exited (here via a - panic), with the item handed back inside the `Disconnected` variant. - Mutation: if `try_send` mapped `Disconnected`→`Full` it would - mis-signal a permanently-dead consumer as transient backpressure. -- `send_returns_item_after_consumer_panicked`: plain `send` must hand - the item back via `Err(item)` once the consumer has gone away - (panic). The first send may race the panic, so the test loops until - one fails and asserts the returned item identity. Mutation: if - `send`'s `Err` arm returned a different/default item, the identity - assert fails. -- `send_with_halt_returns_item_on_disconnect`: `send_with_halt` must - return the exact item via `Err(item)` when the consumer has - disconnected (the `Disconnected` arm). Panics the consumer, waits for - it to fully exit, then calls `send_with_halt` with a live halt and a - long deadline — the only way it can return `Err` is the disconnect - arm. Mutation: if that arm returned a default item instead of the - channel's returned item, the identity assert fails. -- `finish_with_halt_none_does_not_spuriously_halt`: `finish_with_halt(None)` - with a wedged consumer and NO halt token must NOT return early — it - must keep polling until the `JOIN_TIMEOUT_SECS` deadline (it cannot - observe a halt that was never supplied). Since the test can't wait 10 - minutes, it asserts the weaker but still-meaningful property that with - a `None` halt and a wedged consumer, `finish_with_halt` does not - return within a short window (genuinely blocked, not spuriously - returning `Halted`), then releases the consumer and confirms `Ok`. - Mutation: if the `None` branch erroneously treated `None` as - "cancelled", it would return `Halted` immediately and this fails. -- `finish_with_halt_joins_cleanly_when_consumer_finishes_in_grace`: - regression for the "consumer thread / output-file leak on halt" fix. - When the halt fires but the consumer finishes WITHIN the grace period, - `finish_with_halt` must join cleanly and return `Ok` — not leak the - thread or return `Err(Halted)`. Uses a sink that sleeps briefly (well - inside `FINISH_GRACE_SECS`) after the producer drops the channel; - fires the halt immediately so `finish_with_halt` enters the grace - spin, and the consumer finishes during the grace window. Without the - fix (old behaviour: immediate leak on halt) this would have returned - `Err(Halted)` and the `SumSink` total would be unobservable. -- `leaked_consumer_skips_close_after_abandonment`: regression for the - "leaked consumer finalises an abandoned output" bug. When the grace - period expires and the consumer is leaked, the consumer — once its - wedged `apply` syscall returns — must observe the abandonment flag and - exit WITHOUT calling `close()`. For the mux writer, `close()` is where - the MKV is finalised (Cues + segment-header patch); running it on a - file the caller already reported as failed is the write race this fix - prevents. Uses a sink whose `apply` blocks until released (simulating - a wedged write syscall) and records whether `close()` ran; fires the - halt, lets `finish_with_halt` spin through the full grace period and - leak the thread, THEN releases the wedged `apply`. The consumer drains - to EOF (`tx` already dropped) and must skip `close()`. Without the - fix, the leaked consumer would fall through to `sink.close()`. -- `consumer_finishing_in_grace_still_calls_close`: companion to the - above — the abandonment guard must NOT fire on the normal halt path - where the consumer finishes inside the grace window; there, `close()` - runs and the output is finalised as usual. (Covered for the - value-return case by `finish_with_halt_joins_cleanly_when_consumer_finishes_in_grace`; - this one asserts `close()` specifically ran.) -- `finish_with_halt_no_halt_token_normal_completion`: regression that - `finish_with_halt` with no halt token and a consumer that completes - normally must still return `Ok` (the `None`-halt polling path is - unchanged by the grace-period fix) — the pre-existing happy-path test - reproduced with an explicit timing floor to guard against spurious - early returns. -- `send_with_halt_fails_fast_once_apply_has_failed`: a fatal `apply` - error must become visible to the PRODUCER, not only to `finish()`. - The consumer keeps draining after the error (so the producer never - blocks on a dead receiver), which meant every `send_with_halt` - returned `Ok` for the rest of the run: on a 60 GB `mkv://` mux that - hit ENOSPC on the first frame, the mux driver read the entire - remaining title — an hour of optical-drive time — before learning the - write had died. -- `abandon_loses_to_a_close_already_committed`: the abandon/finalise - race. A consumer that has ALREADY committed to `close()` when the - grace period expires cannot be stopped — the finalise is happening — - so the caller must wait for its result instead of reporting the - output as un-finalised. With a plain flag the consumer read it as - clear, the caller then stored it, and the caller returned - `Err(Halted)` (`completed = false`) while a fully finalised MKV (Cues - written, Segment size patched) landed on disk — a truncated rip - indistinguishable from a complete one. diff --git a/docs/pipelined-stream.md b/docs/pipelined-stream.md deleted file mode 100644 index 21e9ef54..00000000 --- a/docs/pipelined-stream.md +++ /dev/null @@ -1,38 +0,0 @@ -# `PipelinedPesStream` - -The read-side of the freemkv mux highway. Given a `DemuxThread` (which has -the producer + demux workers already spawned), a set of codec parsers, and -the title metadata, this struct implements `crate::pes::Stream` by running -codec parse on the caller's thread and emitting `PesFrame`s one at a time. - -## Threading - -The pipeline runs three threads in parallel: - -```text -Thread A: read + decrypt (PrefetchedSectorSource / BytePrefetcher) -Thread B: M2TS demux (DemuxThread) -Thread C: codec parse (this struct, on the caller's thread) -``` - -Communication between A→B and B→C is via bounded channels with recycled -buffer pools — no allocations or memcpys in the steady-state hot loop. - -This is the *only* read-side `Stream` impl in tree. Both the ISO file mux -and the BD-TS (`m2ts://`) file mux input paths are built by -`crate::mux::resolve` (`build_iso_pipeline` / the m2ts pipeline builder) -and hand back a `PipelinedPesStream`; the differences are in how the -producer thread (A) is configured — sector-aligned reads with AACS -decrypt for ISO, raw byte reads for M2TS. (`crate::mux::M2tsStream` -itself is a write-only sink and does not construct this type.) - -## DVD seek-index regression (highway-level test) - -The real CLI mux runs `PsDemuxer → PipelinedPesStream (codec parse) → -frame out`, NOT the codec parser straight into the muxer. The keyframe -flag and per-frame duration the `Mpeg2Parser` sets on each `Frame` must -survive that path (`from_codec_frame`) so the muxer's cluster/cue logic -— which opens a cluster + pushes a cue on `keyframe && track 0` — -actually fires. If the highway dropped the keyframe flag, every video -I-frame would arrive as a non-keyframe and the DVD MKV would get -thousands of clusters with ZERO cues (chapter-seek only, no scrub). diff --git a/docs/pixelogic.md b/docs/pixelogic.md deleted file mode 100644 index 20803ccf..00000000 --- a/docs/pixelogic.md +++ /dev/null @@ -1,199 +0,0 @@ -# Pixelogic label parser — internal rationale - -Long-form notes for `src/labels/pixelogic.rs` internal (non-pub) items, kept -here so the source comments stay within the comment-guard's internal cap. -Each source site links back with `// See docs/pixelogic.md — <topic>`. - -## MAX_STREAMS_PER_TYPE - -Sane upper bound on streams of one type within a single feature section. The -BD STN table caps audio at 32; this generous ceiling stops a crafted blob -with tens of thousands of stream tokens from overflowing the u16 STN -counters (panic in debug, wrap-to-0 in release, which would misnumber -subsequent labels). - -## MAX_VIDEO_SLOTS - -Sane upper bound on the number of DISTINCT video-slot entries one section may -list before the walk gives up on it. A section's stream list opens with its -video slots, and `assign_labels` remembers them to recognise where the NEXT -section starts (see the loop body). The BD STN table admits one primary -video plus at most 32 secondary ones, so a section claiming more than that -is not a stream list — and the memo must not grow without bound on disc -bytes. - -## MAX_REPORTED_UNKNOWN - -How many DISTINCT uncatalogued token components one parse will retain for -the end-of-parse report. Disc bytes are untrusted, so the set that backs the -report is capped: a crafted blob carrying thousands of distinct components -must not grow it without bound. Past the cap the components are still -counted (and still logged individually at debug), just not retained by -name — the report says so. - -## UnknownParts - -Collects the uncatalogued token components one parse ran into, so the run -can report them ONCE at the end instead of either staying silent or -emitting a line per occurrence. - -Why aggregate: an unmapped vendor component is how a forced/SDH/commentary -qualifier goes missing, and a per-occurrence `debug!` is invisible in -practice — the gap only surfaces when a user complains about a mislabelled -track. But a per-occurrence `warn!` is unusable in the other direction: a -disc can carry dozens of per-language segment names that merely COLLIDE -with the `{lang3}_{component}` token shape (localized notice/disclaimer clip -names, for instance), and warning on each would bury real signal under -routine noise. One bounded, deduplicated line per parse is loud enough to -notice and quiet enough to live with. - -`seen`: distinct components, deduplicated and ordered for a stable log line, -bounded by `MAX_REPORTED_UNKNOWN`. `total`: occurrences including ones past -the retention cap. - -## assign_labels - -Walk the extracted token strings of the feature section and emit a -`StreamLabel` per editorial token, numbered in STN order. Split out from -`parse` so the section/numbering logic is unit-testable without a -`SectorSource`/`UdfFs`. - -## placeholder_kind - -The bare `Audio Stream N` / `PG Stream N` slot placeholders pixelogic emits -for a stream with no editorial label, and which list they belong to. `None` -for anything else (including the section's `AR_…` aspect-ratio entry, which -is not part of either numbered list; the `Video Stream N` entries are -consumed by the section-boundary rule in `assign_labels` before they get -here, and belong to neither list either). - -## is_stream_token - -Whether a string has the shape of a pixelogic stream token — -`{lang3}_{component}…` — regardless of whether its components are -catalogued. The gate is exactly the one `parse_token_inner` applies before -it starts classifying, so every token that parser could ever accept is -recognised here as occupying a stream slot, and nothing else is. - -## Test: a_full_subtitle_token_is_never_forced_without_its_own_forced_component - -Immunity pin against the defect measured in the `paramount` parser: a vendor -"forced" marker that sits on a FULL dialogue track's own slot to mean "this -track also contains forced signs", read as "this track is forced" and so -flagging full dialogue tracks forced. - -This grammar cannot express that. The forced marker is a component of a -slot's OWN token, so a forced-narrative pass occupies a slot of its own -(`{lang}_TXT_FOR_`, `{lang}_DUB_`) alongside the language's separate -full-dialogue slot — it is never a parallel array indexed against the full -tracks' slots, which is the shape that let one vendor's marker land on a -dialogue track. - -Mutation: give any full-dialogue component (`SDLG`, `TXT`, `SDH`, `STRI`, -`SCOM`) a forced qualifier of its own. - -## Test: assign_labels_numbers_subtitles_by_stn_slot_not_by_parsed_token - -Taken from a real UHD feature's `SEG_MainFeature`: the PG list has 18 slots, -five of them bare `PG Stream N` placeholders and four more carrying a token -whose only non-language component is a REGION (`fra_CF_`, `spa_LS_`, …) — -which `parse_token_inner` rejects because it signals neither audio nor -subtitle. Every one of those still OCCUPIES an STN slot, so the run of -forced-narrative tokens sits at STN 11-18. Numbering only the tokens that -parse collapsed them onto STN 2-8 — the disc's FULL subtitle tracks — so the -player offered "English (forced)" that renders the whole English dialogue. - -The run is also where the vocabulary half of the same bug shows: the slot at -STN 17 spells its forced-narrative marker `DUB` rather than `TXT_FOR`, and -until `DUB` was catalogued that one track alone stayed unflagged even once -the numbering was right. - -## Test: assign_labels_fpl_section_ends_on_sf_boundary - -The FPL section also ends on an `SF_` marker (not just `SEG_`/`FPL_`). Only -`assign_labels_fpl_section_ends_on_seg_boundary` existed before, which -cannot distinguish a mutated `||` chain from the correct one (any single -true operand already ends the section). This test isolates the `SF_` -alternative specifically. Mutation: `||` -> `&&` in the end-of-section check -would require ALL THREE prefixes to match simultaneously (impossible for a -real single token), so the section would never end on `SF_` alone. - -## Test: assign_labels_section_ends_at_the_next_sections_video_slot - -The feature section also ends where the NEXT section's stream list starts, -which is the only boundary available when the feature playlist is the last -NAMED (`SEG_`/`SF_`/`FPL_`) section in the blob. - -Shape taken from a corpus disc whose feature playlist is the last named -section: the trailing per-language notice/disclaimer cards are emitted as -unnamed sections, each opening with its own `Video Stream 1` / `AR_…` pair -and titled with a plain clip name. Those clip names are `{lang3}_{card}`, so -they pass the stream-token gate and each one advances an STN counter; a -card whose name collides with a catalogued component (`AC` reads as the -AC-3 codec) even emits a label, for an STN slot the feature playlist does -not have. On that disc the walk ran 95 entries past the end of the -feature's own list, fabricated five audio labels at STN 10-14 (the playlist -has 9 audio slots), and reported 94 uncatalogued components — which also -downgraded the whole parse from High to Medium confidence. - -Mutation: drop the repeated-video-slot boundary → `deu_Warning` and -`fra_ND` advance the subtitle counter and `eng_AC` emits a phantom Dolby -Digital label on an audio slot that does not exist. - -## Test: assign_labels_video_slot_memo_is_bounded - -The memo of a section's video slots is built from disc bytes, so it is -bounded: past `MAX_VIDEO_SLOTS` distinct entries the section is not a -stream list and the walk stops instead of retaining them all. Mutation: -drop the length guard → the memo grows with the blob. - -## Test: assign_labels_audio_cap_alone_does_not_stop_subtitle_processing - -The two per-type caps are independent — the loop only stops early once -BOTH audio and subtitle counters have reached `MAX_STREAMS_PER_TYPE`. -Reaching the audio cap alone must not cut off subtitle processing. -Mutation: `&&` -> `||` in the outer stop-condition would break the loop as -soon as EITHER counter reaches the cap, silently dropping a legitimate -subtitle stream that comes after audio saturates. - -## Test: assign_labels_subtitle_cap_alone_does_not_stop_audio_processing - -Companion to the above: with the subtitle counter saturated but audio -still under its cap, a subsequent audio token must still be processed. -Isolates the first `>=` operand (`audio_num >= MAX_STREAMS_PER_TYPE`) from -the second. Mutation: `audio_num >= MAX_STREAMS_PER_TYPE` -> `audio_num < -MAX_STREAMS_PER_TYPE` would flip the stop-condition to trigger whenever -audio is UNDER cap and subtitle is AT/over cap — exactly this scenario — -dropping the trailing audio token. - -## Test: assign_labels_pg_placeholder_advances_sub_counter_only - -A `PG Stream N` placeholder occupies a PG STN slot, so it advances the -subtitle counter exactly as `Audio Stream N` advances the audio one, and -the two counters stay independent. Mutation: skip PG placeholders → every -later subtitle label shifts down by the number of unlabelled PG slots -ahead of it. - -## Test: assign_labels_unclassifiable_token_still_occupies_a_slot - -A token-shaped entry the grammar cannot classify (`fra_CF_` — REGION only, -so it signals neither audio nor subtitle; `jpn_ZZQ_` — an uncatalogued -component) still occupies an STN slot in the list currently being -enumerated. Mutation: skip unclassifiable tokens → later labels shift down. - -## Test: assign_labels_dub_slot_is_forced_in_a_forced_run - -A `DUB` slot sitting inside a run of `*_TXT_FOR_` siblings — the shape both -corpus discs show — yields a forced label on ITS OWN slot, contiguous with -its neighbours. Mutation: classify DUB as audio → the PG run gains a hole -at that slot and the audio list gains a spurious entry. - -## Test: unknown_parts_collapses_a_wall_of_segment_name_collisions - -The per-language notice/disclaimer clip names some discs carry (`{lang}_ND`, -`{lang}_Warning`, …) merely COLLIDE with the token shape. They are not -stream tokens and carry no editorial meaning, so they must stay -uncatalogued — mapping them would attach a qualifier to a stream on the -strength of a filename. What they must do is collapse into ONE report -rather than one line each. Mutation: warn per occurrence → dozens of lines -on an ordinary disc. diff --git a/docs/platform-macos.md b/docs/platform-macos.md deleted file mode 100644 index f11887aa..00000000 --- a/docs/platform-macos.md +++ /dev/null @@ -1,12 +0,0 @@ -# src/io/platform_macos.rs - -Shared macOS `fcntl(F_PREALLOCATE)` definitions. - -The `libc` crate doesn't expose these symbols across all macOS SDK versions, -so we define them locally with values from `/usr/include/sys/fcntl.h`. Two -call sites ([`crate::io::writeback_file`] and `crate::io::sink::preallocate`) -need the same constants and `fstore_t` layout — keeping a single source of -truth here prevents the two copies from drifting. - -Module-level cfg gate lives in the parent (`io/mod.rs`); this file is only -compiled on macOS, so no inner `#![cfg]` is needed. diff --git a/docs/png_filenames.md b/docs/png_filenames.md deleted file mode 100644 index d55bbdcf..00000000 --- a/docs/png_filenames.md +++ /dev/null @@ -1,21 +0,0 @@ -# `src/labels/png_filenames.rs` - -## Why Low confidence, and why this is a first-class parser - -This is a language-only hint (no per-stream purpose/codec), so it runs at -`Confidence::Low` — it never displaces a real framework parser, and it sits -at the same tier as the MPLS floor. It is here so the pattern is a -first-class, testable parser that keeps picking up discs as the corpus -grows, rather than lost logic. Detection is precise: it fires only on the -`_UHD01_{LANG}_Composite` grammar with a `{LANG}` the vocab recognizes. - -## Why the marker+suffix avoid false positives - -`filename_lang` extracts the ISO-639-2 language code from a -`{title}_UHD01_{LANG}_Composite` menu-graphic filename, or returns `None` if -the name does not match the grammar or carries a `{LANG}` the vocab does not -recognize. - -The `_UHD01_` marker plus the `_Composite` suffix keep this from firing on -unrelated PNGs (`KeyComposite4.png`, `LoadingComposite1.png` have no -`_UHD01_{LANG}_` segment). diff --git a/docs/prefetched.md b/docs/prefetched.md deleted file mode 100644 index e6435864..00000000 --- a/docs/prefetched.md +++ /dev/null @@ -1,237 +0,0 @@ -# `sector::prefetched` — design notes - -## Why a producer thread - -The mux consumer (demux + codec parsing + frame output) is single-threaded -by nature (streams are sequential). The mux producer (read sectors + AACS -decrypt) is also single-threaded per-call but does CPU-heavy work (AES per -6144-byte unit). Running both on the same thread means the disk and decrypt -cores sit idle while the demux runs, and vice versa. - -Splitting them across two threads with a bounded channel between lets both -run in parallel — peak throughput becomes `min(producer_rate, consumer_rate)` -instead of `1 / (1/producer + 1/consumer)`. - -## Lifecycle - -The producer thread is spawned by `PrefetchedSectorSource::new` (which -returns `Err` if the OS refuses the thread spawn). It walks the supplied -extent list in order, reads the configured batch size at each LBA, and -sends the resulting plaintext buffer into a `crossbeam_channel::bounded` -channel of small depth (so the producer stays a couple of batches ahead -without unbounded memory growth). - -When the channel sender drops (either because all extents were served or -because the `Halt` token cancelled), the consumer observes `RecvError` on -the next `read_sectors` and treats it as end-of-stream. Errors from the -underlying reader are forwarded verbatim through the channel. - -## Read API - -`read_sectors` ignores its `lba`/`count` arguments — the producer has -already chosen what to read, in the order the extents dictate. This is -sound for the mux read path, which always walks extents sequentially and -never seeks. For random-access callers (sweep patch retries) this wrapper -is the wrong tool — they should keep reading the underlying source -directly. - -## `new` — unit-alignment precondition - -Each extent's `sector_count` should be a multiple of `SECTOR_ALIGNMENT` -(3 sectors / one 6144-byte AACS aligned unit). Blu-ray m2ts extents satisfy -this by spec. If an extent has a trailing 1-2 sectors that cannot fill a -complete unit, the producer surfaces `Error::ExtentNotUnitAligned` through -the channel rather than handing the decrypt step a sub-unit chunk it would -silently leave encrypted. - -## `into_channels` — zero-copy pipeline mode - -The caller (typically `super::super::mux::demux_thread::DemuxThread`) pulls -buffers from `rx`, consumes them, and pushes the empty `Vec<u8>` back -through `recycle_tx` so the producer can re-fill it. The producer-thread -`JoinHandle` stays with the returned `PrefetchedSectorSource` shell; drop -that to join. The shell only holds the join handle and total_sectors for -`capacity_sectors` queries; its `SectorSource` impl becomes invalid after -this call (data has been moved out). - -## Test notes - -- `drop_undrained_source_joins_cleanly`: regression for dropping a - `PrefetchedSectorSource` DIRECTLY — the public `new` + documented - direct-read path, and any error/halt exit before the extents are - drained — must join the producer cleanly. `Drop::drop` used to `join()` - while the struct still held `rx` and `recycle_tx` (sibling fields drop - only AFTER `Drop::drop` returns), so a producer parked in the plain - blocking `tx.send(Ok(buf))` never saw a disconnect and the dropping - thread blocked in `join()` forever — a permanent two-thread deadlock - that cancelling the `Halt` could not escape, because that `send` has no - timeout and never re-polls the token. 300 sectors at batch=3 is 100 - batches against a forward channel of depth `PREFETCH_CHANNEL_DEPTH` (2), - so the producer is guaranteed to be blocked in `send` by the time the - drop runs. Mirrors `byte_prefetcher::drop_endless_prefetcher_joins_cleanly`, - whose `Drop` already had the correct shape. - -- `into_channels_drop_releases_producer`: the CRITICAL regression — after - `into_channels`, dropping the returned forward receiver + recycle sender - must let the producer observe disconnection and exit, so dropping the - `PrefetchShell` (which joins the producer) returns promptly. With the - old clone+forget the leaked endpoints kept the producer blocked and this - join hung forever. - -- `halt_releases_producer`: same property via the halt path — cancel the - token, then the producer must exit and the shell join must complete. The - producer parks in a BLOCKING `tx.send` on the forward channel and only - checks `halt` at the loop top, so `halt.cancel()` cannot interrupt a send - that is already blocked on a full channel. To keep the test deterministic - under load, we drain the forward receiver on a background thread: every - send then makes progress, the producer reaches the loop top, observes - the cancelled halt, and exits — so the shell join completes promptly - regardless of scheduling. (Channel-disconnection shutdown is covered - separately by `into_channels_drop_releases_producer`.) - -- `zero_unit_align_rejected`: `unit_align == 0` must be rejected by the - constructor, not turned into a divide-by-zero panic on the producer - thread. Before the guard, `new_with_events` returned `Ok` and the - producer evaluated `remaining % 0`, panicking ("attempt to calculate the - remainder with a divisor of zero"); `catch_unwind` then reported the - read as `DemuxThreadPanicked` instead of the `InvalidInput` its sibling - parameter gets — a panic printed out of a public constructor's own - thread. - -- `direct_reads_past_pool_depth_do_not_deadlock`: more than 3 sequential - direct `read_sectors` calls must succeed. The recycle pool seeds - PREFETCH_CHANNEL_DEPTH+1 (3) buffers; before the fix the direct path - dropped each drained buffer, so the 4th call deadlocked. - -- `event_fn_fires_bytes_read_per_batch`: the producer-thread `event_fn` - must fire a `BytesRead` event for every batch it reads, with a - monotonically increasing cumulative byte count that reaches the full - extent size at EOF. This is the contract autorip's mux progress bar + - soft-stall watchdog depend on: the resume/multipass paths pass an - `event_fn` so `latest_bytes_read` tracks read-ahead progress and - `wd_last_frame` is refreshed on each sector read (not only on the slower - write cadence). Before this guard nothing asserted the callback fired at - all, so a path that passed `None` (the `resume_remux` regression) - compiled and ran silently with the bar stuck at write-lagged progress. - -- `non_multiple_of_three_extent_errors_on_tail`: an extent whose - sector_count is not a multiple of 3 must not emit a still-encrypted - sub-unit tail. The producer delivers the readable full units, then - surfaces a typed error on the tail instead of a short batch. - -- `short_read_does_not_desync_stream`: a short read (inner source returns - fewer sectors than requested) must advance the extent cursor by the - sectors actually read, not the requested count — otherwise the bytes - between the short read and the request size are silently skipped. - -- `capacity_sectors_sums_all_extents`: `capacity_sectors` returns the sum - of all extents' sector_counts, computed once at construction. Grounding: - doc comment on `total_sectors` — "the sum of each extent's sector_count". - -- `capacity_sectors_clamps_on_overflow`: total-sector accumulation must - clamp at u32::MAX rather than panic (debug overflow) or wrap (release) - on a hostile extent set whose summed sector_count exceeds u32. - Grounding: the `new` comment — "Accumulate in u64 then clamp ... a naive - u32 sum() could panic in debug / wrap in release". - -- `producer_walks_extents_in_order_at_correct_lbas`: the producer must - walk extents in list order and start each extent at its `start_lba` - (plus running offset within the extent), never reorder or merge them. - Grounding: lifecycle doc — "walks the supplied extent list in order" and - `lba = extent.start_lba.saturating_add(offset)`. - -- `batch_trimmed_to_whole_units`: a batch larger than one unit must be - trimmed DOWN to a whole number of 3-sector units before issuing the - read — never a sub-unit count that decrypt would leave partially - encrypted. batch=5 → trimmed to 3 (5 - 5%3). Grounding: the unit-trim - block `sectors -= sectors % SECTOR_ALIGNMENT`. - -- `unit_aligned_extent_delivers_all_and_eofs`: an extent whose - sector_count IS a multiple of 3 must deliver exactly that many sectors - and then cleanly EOF (no error on the final aligned batch). Grounding: - the trailing-tail guard only fires for sub-unit leftovers; a unit-aligned - extent forms full units on its own. - -- `reader_error_propagates_with_kind`: the underlying reader's error must - propagate to the consumer as an error (not Ok(0)/EOF), and its - ErrorKind must survive the round-trip through the channel. Grounding: - the producer's `Err(e) => tx.send(Err(e.into()))` arm, and - `read_sectors`' `Ok(Err(e)) => { self.producer_failed = true; - Err(Error::from(e)) }`. That arm recovers the producer's TYPED error by - downcast rather than blanket-wrapping it as `Error::IoError`, so the - kind survives; the `producer_failed` latch it also sets is what turns - the channel close that follows into `SourceTerminated` instead of a - clean EOF. - -- `inner_source_quitting_early_is_not_reported_as_end_of_stream`: an inner - source that answers a mid-extent read with `Ok(0)` has quit early: the - extent list still has sectors to serve. The producer must say so, not - simply drop `tx` — a closed channel reads as clean end-of-stream, and - `DiscStream::fill_extents` then fabricates zeros for every remaining - sector of the title and reports the pass complete. Same rule as the - panic sentinel: a truncated title must never be finalized as success. - -- `non_sector_multiple_read_rejected`: a read returning a byte count that - is not a whole number of sectors (n % 2048 != 0) must be rejected — - never truncated and advanced, which would split a sector and hand - decrypt a partial unit. Grounding: the `if n % 2048 != 0 { send Err }` - guard. - -- `direct_read_too_small_buffer_errors`: a too-small consumer buffer in - the direct `read_sectors` path must error (InvalidInput), never - silently drop the bytes past `buf.len()`. Grounding: the - `if filled.len() > buf.len()` guard in `read_sectors` ("would silently - drop filled[buf.len()..], desyncing the stream"). - -- `too_small_buffer_repeated_does_not_deadlock_pool`: regression for the - fix-1 deadlock — calling read_sectors with a too-small buffer more times - than the pool depth (PREFETCH_CHANNEL_DEPTH+1 = 3) must NOT deadlock and - the pool must remain usable afterwards. Before the fix, each - too-small-buffer error path returned without recycling the received - buffer, draining the fixed pool. On the 4th call the producer blocked on - recycle_rx.recv() while the consumer blocked on rx.recv() — permanent - deadlock. - -- `delivered_bytes_match_source_exactly`: the producer delivers exactly - the bytes the inner source produced, in order, byte-for-byte. - PatternSource tags each sector with `(lba & 0xff)`, so the assembled - stream must match a reconstruction from the extent's LBA range. Guards - against off-by-one/duplicate/reorder in the offset bookkeeping. - -- `empty_extents_eof_immediately`: an empty extent list must EOF - immediately (capacity 0, first direct read returns Ok(0)) and must not - deadlock. Grounding: the producer's `while ext_idx < extents.len()` loop - body never runs, so `tx` drops and the consumer sees RecvError → Ok(0). - -- `zero_length_extent_is_skipped`: a zero-length extent in the middle of - the list must be skipped (remaining == 0 → advance to next extent) - without emitting a batch and without stalling. Grounding: the - `if remaining == 0 { ext_idx += 1; continue }` branch. - -- `four_sector_extent_errors_on_one_sector_tail`: a 4-sector extent (one - full unit + a 1-sector tail) must deliver the 3-sector unit and then - error on the 1-sector remainder — exercising the trim-within-batch path - (`sectors -= sectors % 3` lands on 3, leaving remaining=1) that then - hits the sub-unit guard on the next iteration. Distinct control flow - from the 8-sector case. Grounding: trailing-tail guard plus the - unit-trim block. - -- `many_extents_drain_without_deadlock`: many sequential direct reads - across MANY extents must all flow through the fixed recycle pool - without deadlock — a stronger version of the pool-depth regression that - also crosses extent boundaries (offset reset to 0, ext_idx advance). - -- `bad_sector_across_channel_is_not_a_transport_failure`: REGRESSION — an - ordinary MEDIUM ERROR bad sector that crosses the prefetch channel must - NOT be classified as a SCSI transport failure. `read_sectors` used to - re-wrap EVERY channel error as `Error::IoError { source }`, and - `is_scsi_transport_failure` matches `IoError` (the wedged-USB-bridge - arm). So a skippable bad sector arrived at `DiscStream::fill_extents` - looking like a dead bus and aborted the whole pass instead of honouring - `skip_errors`. - -- `transport_failure_across_channel_still_classifies`: OPPOSITE-DIRECTION - CONTROL — a genuine transport failure (status 0xFF, wedged USB bridge) - crossing the same channel MUST still classify as a transport failure, so - `fill_extents` / sweep keep aborting the pass instead of zero-filling - every read against a dead bus. diff --git a/docs/progress.md b/docs/progress.md deleted file mode 100644 index f8d4cf35..00000000 --- a/docs/progress.md +++ /dev/null @@ -1,31 +0,0 @@ -# progress.rs — design notes - -## Why one progress signal type - -Pre-0.13.16 the API leaked `pos`, `bytes_good`, `work_done`, `bytes_pending`, -`Finished`/`NonTrimmed` mapfile semantics — and consumers reinvented the math -each time they wanted a percentage. UIs ended up reading one source while -server-side computed from another, producing wrong percentages without -anyone noticing. `PassProgress` + the `Progress` trait exist so there is -exactly one shape every pass emits and every consumer renders from. - -## `PassProgress` field mapping for `PassKind::Verify` - -- `work_done` = sectors read so far -- `work_total` = total sectors in title -- `bytes_good_total` = good + slow + recovered sectors × 2048 -- `bytes_unreadable_total` = bad sectors × 2048 -- `bytes_pending_total` = 0 (verify processes sequentially, nothing pending) - -## Why `PassProgress` is not `Copy` - -`located` carries a `Vec`. Constructed once per (throttled) emission and -passed by reference to `Progress::report`, so this costs one small heap -alloc per UI tick — cheap, and it makes `PassProgress` the single complete -contract a client renders from. - -## `Heartbeat` hot-path cost - -`tick` is cheap on the hot path: it reads one `Instant` and compares. For -pure-CPU inner loops where even that is too much, use `tick_cpu`, which -only consults the clock every 256 calls. diff --git a/docs/ps-mod.md b/docs/ps-mod.md deleted file mode 100644 index f8ee29a6..00000000 --- a/docs/ps-mod.md +++ /dev/null @@ -1,219 +0,0 @@ -# `src/mux/ps.rs` — extended notes - -Relocated prose from doc/inline comments in `ps.rs`, trimmed there to stay -within the comment-guard's prose caps. Each `//`/`///` pointer in the source -names the section here it corresponds to. - -## Stream-ID constant rationale - -- `EXTENDED_STREAM_ID` (`0xFD`): the H.222.0 escape whereby the real stream id - is the `stream_id_extension` carried in the PES extension. HD-DVD `.evo` - puts its VC-1 video (and HD audio) here (a real HD-DVD title: VC-1 on - `0xFD` ext `0x55`); a transport stream never uses it. The elementary-stream - bytes follow the PES header exactly like any other PES — only the routing - key differs. -- `MAX_PS_BUFFER`: a length-0 (unbounded) video PES is delimited by the next - PS-layer boundary; if a corrupt stream declares an unbounded PES and never - follows it with a boundary, `feed()` would otherwise accumulate the entire - input. Past this cap we force the in-progress unbounded PES to flush at - the buffer end so untrusted input cannot drive unbounded allocation. A - real DVD pack/PES is at most a few KB; this leaves generous slack while - still bounding worst-case memory. - -## `dvd_audio_pid` rationale - -The PID is `0xBD00 | sub_stream_id`, which is unique per sub-stream id (AC-3 -/ DTS `0x80..=0x8F`, LPCM `0xA0..=0xA7`). Unlike the old per-codec relative -arithmetic, distinct sub-ids therefore always yield distinct PIDs — so a -mixed-codec title (e.g. AC-3 + DTS, whose sub-ids are 0x80 and 0x88) can -never collide on one PID. This is the single source of truth shared with -`Disc::scan_dvd_titles` (`src/disc/dvd.rs`), which sets each -`AudioStream.pid` from the same function so demuxer output routes through -the title's `pid_to_track`. - -HD-DVD (`.evo` Enhanced VOB) carries Dolby Digital Plus (E-AC-3) on -`private_stream_1` sub-stream ids `0xC0..=0xC7` — a range DVD never uses -(DVD audio is `0x80..=0x8F` / `0xA0..=0xA7`), so admitting it here is purely -additive and cannot change any DVD mapping. The PID is `0xBD00 | sub` just -like the DVD audio ranges, so a mixed HD-DVD title (four DD+ tracks -`0xC0..0xC3`) routes each track to its own distinct PID. - -## `dvd_pid` rationale - -Routes by the REAL on-wire `(stream_id, sub_stream_id)` via the shared -[`dvd_audio_pid`] / [`dvd_subtitle_pid`] tables the scanner also uses — -never per-codec relative arithmetic, which collided on mixed-codec audio -(AC-3 0x80 and DTS 0x88 both mapping to 0xBD00). - -`None` is returned for stream/sub-stream combinations the DVD title scanner -does not assign a PID to (e.g. MPEG audio 0xC0-0xDF, private stream 2, -unrecognized sub-stream ranges). The caller is expected to WARN-and-drop in -that case rather than silently mis-routing the packet. - -## `PsDemuxer::pending_scan` - -Boundary-scan cursor for an unbounded (length-0) PES still waiting for its -terminating PS-layer unit: `(buffer offset of the PES start code, buffer -offset up to which the search has already proved there is no boundary)`. -Both are buffer-relative and are rebased when the buffer drains. - -Without it, every `feed` re-searches the WHOLE accumulated payload from the -PES header: the buffer only stops growing at `MAX_PS_BUFFER`, so a stream -that declares an unbounded PES and then never emits a PS-layer start code (a -corrupt or crafted VOB) makes the demuxer scan up to 4 MiB per call, -quadratic in the bytes fed. Cleared whenever the PES is emitted, so it can -never outlive the packet it describes. - -## `find_ps_boundary` - -Find the next PS-layer unit boundary at or after `from`: a start code whose -ID byte is a pack (0xBA), system header (0xBB), program-end (0xB9), or a -payload-carrying PES stream ID (0xBD..=0xEF). - -A length-0 (unbounded) video PES must be delimited by the next PS-layer unit -— NOT by the next raw `00 00 01`. The MPEG-2 video elementary stream inside -the PES is itself full of `00 00 01 xx` start codes (picture 0x00, slices -0x01..=0xAF, GOP 0xB8, sequence 0xB3); a plain start-code scan would cut the -PES inside its own payload and re-scan the discarded video bytes as bogus PS -units. Restricting the search to PS-layer IDs (>= 0xB9, excluding the video -ES codes below it) frames the unbounded PES at the right boundary. - -Returns `(boundary, searched_to)`. `searched_to` is the offset up to which -every byte has been PROVED not to begin a PS-layer boundary start code, so a -later call over the same buffer (grown at the tail) may resume there instead -of re-scanning the payload from the PES header. When the scan runs off the -end, the last two bytes are NOT proved: a `00 00 01` prefix can straddle the -next feed's boundary by up to two bytes. - -## `parse_pts` — 5-byte PTS/DTS layout (ISO/IEC 13818-1 Table 2-17) - -```text -byte0: [prefix:4][pts 32..30:3][marker:1] -byte1: [pts 29..22:8] -byte2: [pts 21..15:7][marker:1] -byte3: [pts 14..7:8] -byte4: [pts 6..0:7][marker:1] -``` - -## Test: `only_private_stream_2_is_navigation_and_never_a_routable_stream` - -`is_nav()` exists to separate the ONE unmappable stream a DVD is expected to -contain — private_stream_2 (0xBF), the PCI/DSI navigation packs (ISO/IEC -13818-1 Table 2-22) — from every other packet whose `dvd_pid()` comes back -`None`, which is an unexpected, possibly-lost real stream. The mux loops use -the distinction to choose between a silent tally and a per-packet WARN, so -collapsing it to a constant either buries a genuine stream loss in the nav -tally, or floods the log with one warning per navigation pack on every DVD -ever ripped. - -The invariant that ties the two together: `is_nav()` may only ever be true -where `dvd_pid()` is `None` — a packet that routes to a real track must -never be silently classified as navigation. - -## Test: `max_ps_buffer_has_the_documented_value` - -`MAX_PS_BUFFER` is read by its own tests only through the same symbol, so a -mutated arithmetic expression in its definition changes what the symbol -itself evaluates to and every self-referential assertion still passes. Pin -the compiled value against a literal computed independently. - -## Test: `pack_header_exact_fit_is_consumed_not_awaited` - -Pack-header framing (`sc + 14 > len`, then `sc + pack_len > len`) must accept -an EXACT fit — the whole pack (mandatory 14 bytes, or with stuffing) present -and not one byte more — rather than waiting for data that will never come. -Both checks are preceded by an unrelated start code so `sc != 0`: at -`sc == 0` a `sc + pack_len` vs. `sc * pack_len` mutant collapses to the same -value (`0`) and the bound stays unreachable from any input. - -## Test: `system_header_length_high_byte_is_not_dropped` - -`header_len` is a 16-bit big-endian field (`buffer[sc+4] << 8 | -buffer[sc+5]`). A `<<` -> `>>` mutation collapses the high byte to zero, so -any `header_length > 255` is misread as just its low byte — here 300 -(`0x012C`) misread as 44. A start code embedded 50 bytes in (well inside the -true 306-byte unit but exactly where the mis-parsed 50-byte unit would end) -must stay buried in the skipped body under correct parsing, and surface as a -bogus extra PES under the mutant. - -## Test: `find_ps_boundary_handles_a_bare_start_code_at_the_buffer_head` - -`find_ps_boundary`'s bounds check (`sc + 3 >= data.len()`) must stay an -ADDITION: a `+` -> `-` mutation at `sc == 0` underflows the `usize` -subtraction and panics on a plain 3-byte start code with nothing after it — -exactly the tail a real feed can end on. - -## Test: `an_unterminated_pes_is_not_rescanned_from_its_header_every_feed` - -An unbounded (length-0) PES is terminated by the next PS-LAYER unit, and -until one arrives the payload accumulates in the buffer. The search for that -unit must not restart at the PES header on every feed: the buffer only stops -growing at `MAX_PS_BUFFER` (4 MiB), and a feed is one read batch (at most -510 sectors ≈ 1 MiB, 60 sectors ≈ 120 KiB on an optical drive), so -re-scanning from byte 0 costs work quadratic in the bytes fed — up to 4 MiB -of scanning per call, for as long as a corrupt or crafted VOB withholds the -boundary. A conformant DVD ends every pack within 2048 bytes and never -reaches this state. - -Measured directly, because a work bound has no packet-level shadow: -`boundary_bytes_scanned` counts the bytes `find_ps_boundary` examines. 256 -chunks x 4 KiB of boundary-free payload is 1 MiB of input; re-scanning from -the header on every call examines 4 KiB * 256*257/2 = ~128 MiB. - -Mutation: drop the `Some((pes_at, searched_to)) if pes_at == sc` arm so -`from` is always `sc + 4`. - -## Test: `a_resume_cursor_survives_the_drain_of_units_ahead_of_the_unbounded_pes` - -The resume cursor is a BUFFER offset, so it must be rebased when the buffer -drains — and this is the only test in which a drain actually happens while a -cursor is live. - -Mutations this catches, both halves of -`self.pending_scan.map(|(pes_at, searched_to)| (pes_at - pos, searched_to - pos))` -in `extract_packets`: - * `searched_to - pos` -> `searched_to`: the next call resumes `pos` bytes - PAST where the previous scan actually stopped, so that window is never - examined. Here the terminating pack header lands inside it and is missed - outright — the unbounded PES runs on past its real end, swallowing the - following unit. That is a CORRECTNESS failure, not a slow path, and the - assertion on the emitted packet catches it. - * `pes_at - pos` -> `pes_at`: the stale offset no longer equals the PES's - post-drain `sc`, the resume arm stops matching and the search restarts - at the PES header. Caught by `boundary_bytes_scanned`, which is why the - fixture puts 64 KiB of payload in the SAME chunk that opens the PES: - that is exactly the span a restart re-examines, so the mutant roughly - doubles the bytes scanned. - -`an_unterminated_pes_is_not_rescanned_from_its_header_every_feed` cannot -reach either: it opens the unbounded PES as the very FIRST bytes of the very -first feed, so nothing ever drains ahead of it, `pos` stays 0 and the -subtraction is a no-op. The comment above it asserts neither component can -underflow; nothing exercised the arithmetic at all. - -So this fixture puts COMPLETE PS units — a pack header and a length-bounded -PES — ahead of the unbounded video PES *in the same chunk*. The loop -consumes them, breaks on the unbounded PES, and drains `pos` bytes with -`pending_scan` live: exactly the real DVD shape, where a video PES opens -partway through a read batch. - -## Test: `parse_stream_id_extension_walks_every_optional_field_to_the_right_offset` - -`parse_stream_id_extension` walks every optional PES-header field (PTS/DTS, -ESCR, ES_rate, DSM_trick_mode, additional_copy_info, PES_CRC) and every -optional PES_extension sub-field (PES_private_data, pack_header_field, -program_packet_sequence_counter, P-STD_buffer) before reaching -`stream_id_extension`. Every one of those skips is a `pos +=`; a single -mutated increment (`-=`/`*=`) misaligns every read after it. This test arms -EVERY optional field at once with a known byte count, so any single wrong -skip anywhere in the chain lands on the wrong byte and the assertion fails — -one test proving the whole walk, rather than one per field. - -## Test: `length_bounded_pes_exact_fit_is_emitted_not_awaited` - -A length-bounded PES (`pes_packet_len != 0`) must be emitted the moment its -declared length is EXACTLY satisfied by the buffer (`sc + 6 > len`, then -`e = sc + 6 + pes_packet_len; e > len`), not held back waiting for a byte -that will never arrive. Feed nothing after the packet and don't flush — if -the boundary checks were `>=` instead of `>`, an exact fit would incorrectly -be treated as "not enough data yet" and the packet would never be produced. diff --git a/docs/reorder.md b/docs/reorder.md deleted file mode 100644 index 0b5db528..00000000 --- a/docs/reorder.md +++ /dev/null @@ -1,59 +0,0 @@ -# Display-order PTS reconstruction for sparse-PTS program-stream video - -MPEG program streams (DVD VOB, HD-DVD EVO) timestamp video at GOP -granularity: only one access unit per GOP carries a PES PTS, and the rest -arrive with none. The H.264 / HEVC / VC-1 parsers collapse a missing PTS to -`0` (`pes.pts.or(dts).unwrap_or(0)`), so on such a source every non-anchor -frame lands on the same block timestamp. A decoder then cannot order them and -reports "non monotonically increasing dts". (The MPEG-2 parser already avoids -this by reconstructing per-picture PTS from `temporal_reference`; these three -codecs carry no such field.) - -`SparsePtsReorder` reconstructs a display-order PTS for every frame from two -signals the parsers already provide — the coded picture type (I/P/B) and the -sparse anchor PTS — plus a per-frame duration self-calibrated from the spacing -between consecutive GOP anchors (no external frame-rate needed). It mirrors -the MPEG-2 parser's GOP-buffered origin-locking, but derives display order via -the classic single-anchor-delay rule instead of `temporal_reference`: - -- In DECODE order an anchor (I/P) is stored before the B-frames that - reference it forward, so decode `I P B P B` displays as `I B P B P`. -- The rule that produces that mapping: an anchor is displayed only after the - previously-held anchor; a B-frame displays immediately. This is exact for - the classic (non-hierarchical) GOP structures HD-DVD H.264/VC-1 use. - -This reconstruction is applied ONLY on the program-stream path -(`ContentFormat::MpegPs`). BD/UHD transport streams carry a per-frame PTS and -are never routed through it, so the primary decode path is untouched. - -## `FALLBACK_FRAME_DUR_NS` - -Fallback per-frame duration (ns) when the anchor spacing cannot calibrate one -(a stream with a single GOP, or no anchor PTS at all): 24000/1001 fps film, -the dominant HD-DVD cadence. Only affects intra-GOP spacing — each GOP's -origin is re-locked to its own anchor PTS, so a wrong fallback cannot drift -the timeline across GOPs. - -## `MAX_GOP_FRAMES` - -Force-complete the current GOP once it reaches this many buffered pictures -even without a keyframe. A GOP is normally a few dozen frames; a stream that -never signals a keyframe (open-GOP recovery-point coding, or crafted/corrupt -disc bytes) would otherwise buffer every access unit — the whole title — in -RAM. Mirrors the MPEG-2 parser's `MAX_PENDING_FRAMES` backstop so no -reassembly buffer grows unbounded on disc-controlled input. - -## `MAX_GOP_BYTES` - -Byte cap on the buffered GOP, complementing `MAX_GOP_FRAMES`. A GOP holds a -couple hundred MB at most in practice; this force-completes a run of -few-but-huge access units so a crafted/corrupt stream cannot over-allocate -(the AU assembler caps each frame at 8 MiB, so 600 frames alone could reach -~5 GiB without this). - -## `display_indices` - -Display index (0-based, decode order in → decode order out) for a GOP's coded -picture types via the classic single-anchor-delay reorder: an anchor (I/P) is -displayed only after the previously-held anchor; a B displays immediately. -Decode `I P B P B` → display indices `[0, 2, 1, 4, 3]` (display `I B P B P`). diff --git a/docs/resolve.md b/docs/resolve.md deleted file mode 100644 index c54a51d8..00000000 --- a/docs/resolve.md +++ /dev/null @@ -1,1049 +0,0 @@ -# Stream URL resolver - -`src/mux/resolve.rs` parses URL strings (`scheme://path`) into PES stream -instances. - -## Scheme table - -| Scheme | Input | Output | Path | -|--------|-------|--------|------| -| disc:// | Yes | -- | empty (auto-detect) or /dev/sgN | -| disk:// | Yes | -- | alias for `disc://` (identical behavior) | -| iso:// | Yes | -- | file path (required) | -| mkv:// | Yes | Yes | file path (required) | -| m2ts:// | Yes | Yes | file path (required) | -| network:// | Yes (listen) | Yes (connect) | host:port (required) | -| stdio:// | Yes (stdin) | Yes (stdout) | empty | -| null:// | -- | Yes | empty | -| demux:// | -- | Yes | directory path (required) — per-track ES demux | -| fvi:// | -- | Yes | file path (required) — per-picture video index | - -Bare paths without a scheme are rejected. - -## `image_input` (shared IMAGE-level source body) - -`iso://` and `dir://` differ only in how the sectors are produced — a file -versus a synthesized UDF volume over a folder — and not at all in what is -done with them: scan, apply caller-resolved keys, gate on decryptability, -select the title, prune streams, correct TrueHD channel counts, mux. One -body means a `dir://` source cannot drift away from the `iso://` behaviour -that has years of fixes in it. - -`reopen` yields a SECOND, independent reader for the TrueHD channel probe, -which must not disturb the mux reader's position. It returns `Option` -because a failed re-open is non-fatal: the correction is skipped, not the -mux. - -## `FMTS_POOL_TAG_BASE` - -CPS-unit id given to an FMTS forensic **index key** banked into the caller's -key pool by `resolve_fmts_key_map` (`FMTS_POOL_TAG_BASE + slot`). - -The pool entry's first field is a CPS-unit number that the mapped decrypt -never reads — it indexes the pool by SLOT (`crate::decrypt::AacsKeyMap` -ranges carry slots) — so the field doubles as the tag that separates the -disc's BASE CPS unit keys from the forensic index keys appended on top of -them. Base ids come from a key's position in `Unit_Key_RO.inf` (+1), whose -count is a BE16, so they cannot exceed 65_536; this base is far above that, -so the two id spaces can never collide and misclassify a base key as -forensic (which would under-count the CPS units and hand the wrong key to a -whole extent). - -## `single_base_key_slot` - -The pool slot of the disc's ONE base CPS Unit Key, or `None` when the pool -holds several (a genuine multi-CPS disc) — the forensic index keys -(`FMTS_POOL_TAG_BASE`) that `resolve_fmts_key_map` appends to the same pool -are excluded, so the answer is a property of the DISC and not of how many -titles have already resolved through the shared pool. - -## `base_slot_for_extent` - -Which CPS unit's BASE Unit Key opens `ext`, as a pool slot — decided by this -extent's own ciphertext, exactly like the multi-CPS path in -`resolve_mux_key_map_cached`, and memoised in the same per-disc -`CpsUnitCache` so a clip several playlists share is sampled once. - -Only base keys are considered: the forensic index keys share the pool but -belong to segment ranges, which are already mapped by tag before this runs. - -`last_idx` is the slot resolved for the PRECEDING extent of this title, -carried into an extent with no sampleable encrypted units (nothing to -mis-decrypt). An extent that does have real ciphertext no held or fetched -key opens is a fail-loud `crate::error::Error::DecryptFailed` rather than a -silently wrong key over the whole extent. - -## `resolve_fmts_key_map` - -FMTS (AACS 2.1) branch of `resolve_mux_key_map`. Returns `Some(map)` when the -disc carries `IndividualSegment.tbl` AND a key source is configured; `None` -otherwise (not FMTS, or no source — the caller's base-Unit-Key path then -applies, and the forensic units garble and are dropped by the demux). - -The forensic segments each carry an **index** tag (1..32) selecting one of 32 -**index keys** the base Unit Key cannot open (see `crate::aacs::segment`). -This resolves those keys up front from the configured source — sending, per -index, a batch of same-index units the service maps to that index's key — adds -them to the pool, and builds a per-segment LBA→key map. Applying a segment's -key over its whole range decodes the ~40 units of that index's interleave half -to clean TS and garbles the other ~40 (the alternate half), which the demux -then drops, yielding one coherent stream. The base Unit Key covers everything -outside a segment. - -The segment SPNs are offsets into the FORENSIC FEATURE CLIP, so every -clip-byte → LBA mapping here is anchored on that clip's own extents -(`forensic_clip_extents`) — never on `title.extents`, which on a play-all -playlist is the concatenation of several clips and would put the segments in -the wrong one. - -Everything expensive here is memoised per DISC, not recomputed per title: -`cache.table` holds the UDF walk + `IndividualSegment.tbl` and `cache.clip` -the forensic clip's extents (both disc-invariant outright), and `cache.keys` -holds the index keys + phases. See `resolve_mux_key_map_cached` for why a hit -is provably the same answer. What remains per title is the pool→slot -mapping, the LBA range arithmetic and the base-key gap fill. - -The gap fill covers each non-forensic LBA with the base Unit Key of the CPS -unit it belongs to. On the single-base-key disc that is every FMTS disc seen -so far that costs nothing; a disc with several base CPS Unit Keys resolves -each extent from its own ciphertext through `cps` (`CpsUnitCache`, shared -with the multi-CPS path so a clip is sampled once per disc). - -## `probe_fmts_index_keys` - -The drive- and key-service-hitting half of `resolve_fmts_key_map`: anchor the -disc's whole forensic index-key set from ONE key-service round trip, then -probe each index's interleave phase. Split out so the result can be memoised -per disc (`FmtsKeyCache`) — the arithmetic that turns these keys into a -per-title LBA map stays at the call site, where the title belongs. - -`clip_extents` are the FORENSIC FEATURE CLIP's own extents — the byte space -the segment SPNs are relative to — and `segments` those addressable within -it; every read is `clip_byte_to_lba(clip_extents, …)`, so a probe never lands -in another clip's sectors whatever order a playlist lists its clips in. -Neither probe reads the caller's key pool, which is what makes the result -independent of resolve order across titles. - -## `forensic_clip_extents` - -The FMTS forensic feature clip's own extents — the byte space every -`IndividualSegment.tbl` SPN is relative to — or `None` when the disc does not -identify exactly one such clip. - -An AACS 2.1 disc names its forensic feature `BDMV/STREAM/<clip>.fmts` (the -extension `bluray.rs` already resolves clip extents through), and the disc -carries ONE segment table, whose SPNs are therefore in ONE clip's byte space. -So: exactly one `.fmts` file → that clip's extents; none, or several (an -ambiguous SPN space), → `None`, which `resolve_fmts_key_map` turns into a -loud `Error::FmtsKeyMissing` rather than a guess. - -This is a DISC fact — no title is consulted — which is exactly why it is a -sound anchor: a playlist's `extents` are the concatenation of ALL its clips -in playback order, so their byte space is the playlist's, not the forensic -clip's. - -A read fault on the clip's ICB propagates (`Err`), like every other read on -this path; a purely structural absence is `Ok(None)`. - -## `filter_addressable_segments` - -Keep only the forensic segments addressable within the FORENSIC CLIP's -extents: a segment whose clip-byte start (`start_spn * 192`) maps to an LBA -inside the clip is real forensic content; one that does not is past the -clip's end (a stale or foreign table record) and is dropped. An empty result -means there is nothing forensic to resolve, so `resolve_fmts_key_map` returns -`Ok(None)` and the caller's base Unit-Key path applies. Extracted from -`resolve_fmts_key_map` for direct testing of the inclusion/exclusion -decision. - -## `resolve_tie_phase` - -Decide a forensic index's decrypt phase from the clean-sample counts of its -EVEN vs ODD aligned units under that index's key. Extracted from -`resolve_fmts_key_map` so the tie logic is unit-testable; the `tracing` -diagnostics stay at the call site, which holds the segment-index context. - -* `even > odd` → `Phase::Even`; `odd > even` → `Phase::Odd` — the clean half - is this index's real content variant. -* `even == odd == 0` → `Error::FmtsKeyMissing`: NEITHER half decrypts clean, - so the key is wrong (or the sample is not this index's content) — fail - loud rather than emit a broken segment. -* `even == odd > 0` → `Phase::Even`: BOTH halves are clean, i.e. source-zero - padding (clean under any key), so the parity is immaterial — default Even. - -## `IndexProbe` - -Outcome of probing ONE forensic index's decrypt phase (see -`probe_index_phase`). The load-bearing distinction is between the last two: a -genuine wrong key and a transient live-drive read fault both leave zero clean -decrypts, but only the former is a real `FmtsKeyMissing` — the latter must -NOT abort a rip whose index keys are valid. - -## `probe_index_phase` - -Probe one forensic index's decrypt phase by reading a representative -segment's EVEN vs ODD aligned units and counting clean decrypts under `key`. -Extracted from `resolve_fmts_key_map` so the read-fault-vs-wrong-key decision -is directly testable without a full UDF/segment-table fixture. - -Mirrors the anchor loop's read-fault tolerance: try up to `max_segments` -same-index segments (rather than a single `.find`), skipping any whose reads -all fault, and only conclude `IndexProbe::WrongKey` once a segment actually -yielded decrypt attempts. If EVERY read of EVERY same-index segment faults, -return `IndexProbe::ReadFault` — the caller then leaves the phase unresolved -(defaults to `Phase::All`) instead of hard-aborting the rip. `read(seg, -unit)` reads aligned unit `unit` of `seg`; `None` is a read fault. - -Masking guard: `IndexProbe::ReadFault` is returned ONLY when not a single -read succeeded, so a genuine wrong key (whose reads DO succeed) can never be -masked as a read fault — any successful, non-clean decrypt yields -`IndexProbe::WrongKey`. - -## `fill_base_key_gaps` - -Back-fill the LBA gaps NOT covered by the forensic segment ranges with the -base Unit Key, so the finished map is a COMPLETE positive list over the -title's content extents: every content LBA resolves to either a forensic key -(inside a segment) or the base key (`base_idx`). An LBA left in no range -would pass ciphertext through as clear — this range arithmetic guarantees -there is no such hole inside any extent. Extracted from `resolve_fmts_key_map` -for exhaustive direct testing (gaplessness over every extent). - -`forensic_ranges` are the already-built per-segment ranges; only their -`[start, end)` spans matter here (they carve the holes — the key idx / phase -are irrelevant). The return is the base-key fill ranges ONLY; the caller -appends them to `forensic_ranges` to form the full map. - -## `resolve_mux_key_map` - -This is what ends the key-server storm. The old mux decrypted a unit, checked -whether the plaintext looked like clean MPEG-TS, and — because authored-bad -content never reaches that bar — re-asked the key service for a key it -already held. There is no per-unit byte pattern that separates "correctly -decrypted but authored-bad" from "still encrypted", so that check is -unanswerable. Here we answer the answerable question instead: which CPS unit -does each LBA range belong to, decided by the disc's key structure (validated -once against real ciphertext samples, where the `is_clean` proof IS sound). -The mux then just decrypts each unit with its mapped key and trusts it. - -Single-CPS (the overwhelming majority, incl. every single-key UHD) keys every -content extent with one index; multi-CPS keys each extent with the key that -opens a real sample from it; FMTS layers per-segment index keys on top. Any -LBA outside the title's content (nav/filesystem) is in no range and passes -through. - -## `CpsUnitCache` - -Memoises the multi-CPS "which held unit key opens this extent" decision -across the titles of ONE disc, for `resolve_mux_key_map_cached`. - -Keyed by content format plus the extent's exact `(start_lba, sector_count)`, -so a hit returns the index that was resolved from *those same physical -bytes* — see the safety argument on `resolve_mux_key_map_cached`. Only -successfully resolved extents are memoised; a no-samples extent (whose index -is inherited from the preceding extent of the SAME title, i.e. not a -property of the extent) and the fail-loud "no key opens it" outcome are -never cached. - -A disc's playlists overwhelmingly reference the same handful of clips (main -feature, play-all, per-chapter and seamless-branch variants), so without this -the same extents are re-sampled off the drive once per playlist: 8 random -6144-byte reads each, ~200 ms of seek apiece on a stock BD drive. - -Scope, stated precisely: this memo covers the extent-sampling reads that -decide which CPS unit an extent belongs to, and nothing else. On an FMTS -(AACS 2.1) disc `resolve_fmts_key_map` runs first and returns a finished map -before the extent loop below is ever reached, so the loop's reads are -removed by `FmtsTableCache` and `FmtsKeyCache` instead — but a MULTI-CPS -FMTS disc samples through this same memo from the gap fill -(`base_slot_for_extent`), and the cached value means the same thing on both -paths: the pool slot whose key opens that extent's own ciphertext. - -## `FmtsTableCache` - -The disc's forensic segment table (`/AACS/IndividualSegment.tbl`), resolved -at most ONCE per disc: `None` = not looked for yet; `Some(None)` = looked for -and this disc is not FMTS; `Some(Some(v))` = the parsed, non-empty segment -list. - -Every input is a property of the MEDIA, not of a title: the UDF walk -(`crate::udf::read_filesystem`) reads fixed low LBAs (the anchor at 256, the -VDS, the FSD, the root directory), `read_file` follows that file's own -allocation descriptors, and `parse_individual_segments` is pure. Re-running -it per title re-reads ~35 single sectors at low LBAs from a head the -previous title's content sampling left deep in the content area — a -full-stroke seek out and back per playlist, for a byte-identical answer. - -Only the two *deterministic* negatives are memoised as "not FMTS" -(`UdfNotFilesystem`, and `UdfNotFound` for the table): a read fault is NEVER -cached, so a transient failure still propagates and a later title still -retries. - -## `FmtsKeyCache` - -Memoises the FMTS (AACS 2.1) forensic **index-key set and per-index phase** — -the expensive half of `resolve_fmts_key_map` — across the titles of ONE disc. - -Keyed by `(format, the title's exact extent list)`, mirroring -`crate::disc::pgs_forced_probe::ForcedProbeCache`. Every read the anchor -probe, the phase probe and the key-service call make is -`clip_byte_to_lba(forensic_clip_extents, …)` and the probed segments are -`filter_addressable_segments(_, forensic_clip_extents)` — both DISC facts -(see `forensic_clip_extents`), so the answer no longer varies by title at -all. The extent list is KEPT in the key deliberately: it is finer than the -answer needs, so each distinct extent list still gets its own verdict rather -than inheriting another's, and a title can never be served an answer -resolved from bytes it does not read. Two titles with the same extent list -feed byte-identical samples to a stateless `crate::sector::KeyFetch` and to -the same `is_clean` arithmetic under the same `format` — see the safety -argument on `resolve_mux_key_map_cached`. - -The value holds the ordered index keys (element `i` = forensic index `i + 1`) -and the resolved phase per index tag. It is key material: never logged, -never rendered, and dropped with the disc's resolve. - -## `FmtsClipCache` - -The disc's forensic feature clip extents, resolved in the SAME UDF walk as -`FmtsTableCache` and at most once per disc: `None` = not looked for yet; -`Some(None)` = looked for and not identifiable (or the disc is not FMTS); -`Some(Some(v))` = the clip's extents, the anchor for every segment SPN. - -A disc fact like the table itself — see `forensic_clip_extents` for why, and -for what `Some(None)` costs on an FMTS disc (a loud `FmtsKeyMissing`, not a -guess). - -## `resolve_mux_key_map_cached` - -`resolve_mux_key_map` with a caller-owned per-disc cache (`DiscKeyCache`) -shared across the titles of one disc. - -### Why a cache cannot change a resolved key - -#### Multi-CPS extents (`CpsUnitCache`) - -The cached value is the pool index `pick` chose for an extent, and every -input to that choice is stable across the titles of one disc: - -* The samples are a deterministic function of the extent's `(start_lba, - sector_count)` and `format` — both in the key — read from unchanging media. -* The key pool is APPEND-only here (base-key fetch and the FMTS resolver only - push), and `pick` returns the FIRST pool entry that opens a sample, so a - later, longer pool yields the same first match for the same samples. -* `crate::sector::KeyFetch` is stateless by contract, and any key a hit's - index refers to was already banked into the pool on the miss that filled - the entry — so skipping the re-fetch skips no side effect the map depends - on. - -#### FMTS segment table (`FmtsTableCache`) - -Disc-invariant outright: no input to the UDF walk, the `read_file` or the -parse mentions the title. See the type's doc for the negatives that are -(and are not) memoised. - -#### FMTS forensic clip extents (`FmtsClipCache`) - -Disc-invariant outright, like the table: the clip is found by name in the -UDF tree and its extents come from its own allocation descriptors. No title -is consulted — which is the point. It is the byte-space anchor for every -segment SPN, and anchoring on a TITLE's extent list instead made a playlist -that lists a trailer before the feature map every segment into the -trailer's sectors. - -#### FMTS index keys and phases (`FmtsKeyCache`) - -Every input to the anchor and phase probes is now a disc fact: the probes -read `clip_byte_to_lba(forensic_clip_extents, …)` and visit -`filter_addressable_segments(_, forensic_clip_extents)`. The title's extent -list stays IN the key anyway — a strictly finer key than the answer needs, -so no title inherits a verdict resolved from bytes it does not read. Given -the same key, the same `format` and unchanging media: - -* `filter_addressable_segments` yields the same segments in the same order, - so the anchor loop and each `probe_index_phase` visit the same segments. -* every probe read is at the same LBA, so the same ciphertext is fed to a - `crate::sector::KeyFetch` that is stateless by contract, and to the same - `aacs_unit_encrypted` / `decrypt_unit` / `is_clean` arithmetic. -* NEITHER probe reads the key pool. The anchor takes its keys from `fetch`; - the phase probe takes them from the anchor's reply. So the pool's growth - across titles — the one thing that does change between calls — cannot - move this result, and the cached value is independent of the order titles - are resolved in. - -What is deliberately NOT memoised, which is what makes this safe rather than -merely faster: - -* the fail-loud `FmtsKeyMissing` verdicts (no anchor, wrong key), so a retry - after a key source is reconfigured re-probes rather than inheriting - nothing; -* a run where any index's phase probe came back `IndexProbe::ReadFault` — - that leaves the phase defaulted to `Phase::All` (degraded but complete), a - property of a transient live-drive fault and NOT of the extents. Caching - it would spread one bad read across every remaining title. - -Everything downstream of the cache still runs per title: the pool insertion -that turns index keys into slots, the per-segment LBA range arithmetic, and -the base-key gap fill — all of which genuinely depend on this title's -extents. - -Halt is polled per extent AND once on entry to the FMTS branch, so a -60-playlist sweep stays cancellable even when every title is served from -cache. - -## `build_iso_pipeline` parameters - -Assembles the ISO mux pipeline (read+decrypt → demux → parse) for a -`FileSectorSource`-backed reader. Returns the resulting `PipelinedPesStream`. - -- `reader`: the sector source to read from (typically a `FileSectorSource` - over the ISO image). -- `title`: the selected title; its `extents` drive the read range and its - `streams` build the demux/parse tables. -- `keys`: decryption keys applied per sector batch. Pass - `crate::decrypt::DecryptKeys::None` for raw / unencrypted reads (the - decrypt decorator then becomes a pass-through). -- `batch_sectors`: read batch size in logical (2048-byte) sectors — a - throughput/latency tuning knob, not a correctness parameter. -- `format`: container format (`BdTs` → TS demuxer, `MpegPs` → PS demuxer). -- `raw`: ciphertext passthrough. When `true`, the per-title CSS crack - (`resolve_dvd_title_key`) is skipped entirely — no key is resolved and a - scrambled title is neither descrambled nor hard-failed. -- `halt`: cooperative cancel token (not a timeout); when cancelled the - pipeline stops at the next boundary (and the CSS crack surfaces `Halted`). - `None` disables cancellation. -- `event_fn`: optional progress/event callback invoked by the prefetcher. -- `fetch`: optional key source used UP FRONT by `resolve_mux_key_map` to - secure any CPS-unit key the pool is missing. Not a per-unit mux-time - callback: the map decides the key for every LBA before the read loop - starts. - -Nine reader/title/keys/tuning/callback params is inherent to the mux entry -point; grouping them into a struct would only move the same fields around. - -## `parse_url_never_panics_on_adversarial_input` - -`parse_url` must never panic on ANY input — it is the front door for -caller-supplied URL strings, so a panic here would crash the binary on -malformed input instead of surfacing a clean error downstream. Feed it a -battery of adversarial strings (empty, doubled/garbled schemes, embedded -NUL, unicode, a very long path, lone scheme markers) plus an exhaustive -sweep of every single byte 0x00..=0xFF as the whole input and as a scheme -suffix. Any `StreamUrl` variant is an acceptable result; the only failure -mode under test is a panic. - -## `fvi_output_records_the_source_not_the_destination` - -`fvi://` must record the SOURCE in `source.{path,medium,title}`. It used to -pass the DESTINATION path as `FviSink::create`'s `source_path` (and default -the medium/title), so every index claimed to be its own source — -`docs/FVI_FORMAT.md` §6.2 defines `source` as describing the input. - -## `fvi_output_is_reproducible_across_destination_paths` - -The property the destination-as-source defect broke: two runs indexing the -SAME source must produce byte-identical output regardless of where they -write. Previously the header embedded the destination path, so the two -files differed (and differed in length when the paths differed in length) -purely from where they landed. - -## `parse_dir_url_is_an_image_source_unlike_the_directory_sinks` - -`dir://PATH/` parses to `StreamUrl::Dir` with the raw remainder as the path. -Unlike the other directory schemes it IS an image-level source (1.6.1): -`crate::dirimage` synthesizes a UDF volume over the folder, so -`is_disc_source()` — "has a filesystem to scan" — is true for it and false -for the write-only `demux://` / `fvi://` directory sinks. - -## `dir_url_is_an_input_but_never_a_pes_sink` - -`dir://` is never a PES SINK — it writes raw decrypted files, not muxed -frames, so `output()` still rejects it (StreamReadOnly → Unsupported) and -the CLI routes a `dir://` dest to `Disc::extract_tree`. - -As a SOURCE it is no longer rejected out of hand: it is an image source, and -a missing folder now fails as a missing folder (NotFound) rather than as -"this scheme cannot be read". - -## `build_demux_state_bdts_builds_ts_demuxer_and_pid_table` - -BdTs format must build a TsDemuxer (Some(ts), None(ps)) when there is at -least one PID, and one parser + pid_to_track entry per stream keyed by the -stream's own PID. (Mis-keying here is exactly the class of bug that -mis-routes PES into the wrong codec parser.) - -## `HaltCountSource` - -A counting `SectorSource` over zeros. `touched_extent` flags whether any -read landed in the title's extent region (LBA >= 1000); the UDF probe only -reads near LBA 256 (small `capacity`), so a hit there means the expensive -per-extent `sample_units` loop ran. - -## `resolve_mux_key_map_honors_pre_cancelled_halt` - -`resolve_mux_key_map` on the multi-CPS live path must honor a pre-cancelled -halt PROMPTLY — `Err(Halted)` at the first extent boundary, before sampling -any extent's ciphertext — rather than reading through every extent. This is -the round-2 Fix 1 guard: the resolve chain runs on the LIVE drive (each -`read_sectors` can stall to the SCSI recovery timeout), so an operator Stop -during key resolution must interrupt it. - -Mutation: dropping the `halt.is_some_and(...) → Err(Halted)` check in the -multi-CPS extent loop makes the resolve run the sampling reads and return -`Ok(map)` (zeros sample to no encrypted units → carry key 0), so -`expect_err` fails AND `touched_extent` flips true. - -## `bdts_data_packet` - -Build a 192-byte BD-TS data packet on `pid` carrying `payload` as the TS -payload (payload-only adaptation). Layout: 4-byte TP_extra_header (zeros) + -188-byte TS packet (sync 0x47, PID, PUSI, AFC=0b01). Mirrors the BD-TS -framing in ts.rs. - -## `build_iso_pipeline_empty_extents_clean_eof` - -Empty extents → the producer thread exits immediately, the demux thread sees -a clean channel close and emits the Eof sentinel, and the PipelinedPesStream -returns Ok(None) on the first read. The highway must terminate cleanly (no -panic, no hang) when there is nothing to read. - -## `build_iso_pipeline_delivers_one_frame_then_eof` - -End-to-end: one BD-TS packet carrying a complete audio PES flows read → -decrypt(passthrough) → TS demux → codec parse → one PesFrame. Proves the -full highway wiring delivers the ES payload intact and reaches a clean EOF -afterward (never silently truncating the frame). - -## `build_iso_pipeline_pruned_title_drops_unselected_pid_frames` - -End-to-end proof of stream selection: a title declaring TWO audio PIDs, -pruned to one via `StreamSelection::apply` BEFORE `build_iso_pipeline`, must -never surface a frame from the excluded PID. The demuxer is built from the -pruned `title.streams`, so the excluded PID is untracked and its packets are -skipped — track headers and frames both follow the pruned list, which is -the whole point of the selection seam. - -## `build_iso_pipeline_dvd_none_keys_scrambled_hard_fails` - -REGRESSION (autorip production corruption): `build_iso_pipeline` for a DVD -(MPEG-PS) with `None` keys — what autorip's mux passes on a detection-miss -DVD (`disc.decrypt_keys()` == None) — must resolve the CSS key from the -reader itself. A scrambled-but-uncrackable title must HARD-FAIL, never build -a passthrough pipeline that muxes the scrambled sectors as corrupt video. -Before this fix, autorip handed None straight through and the mux wrote -garbage at exit 0. - -## `content_map_builds_exact_ranges_from_extents` - -`content_map(title, idx)` keys every single-CPS UHD disc (the common case): -each content extent → one `[start_lba, start_lba+sector_count)` range at -`idx`, phase `All`. Assert the exact ranges — an off-by-one on the end (or a -wrong idx / phase) must flip this test to FAIL. - -## `filter_addressable_segments_keeps_only_in_title_segments` - -BEHAVIOR 1 — segment filter (`resolve_fmts_key_map` line ~800). A segment -whose clip-byte start (`start_spn * 192`) maps inside the title's extents is -kept; one whose start is past the clip is dropped; all-outside → empty (the -resolver then returns `Ok(None)` and the base-UK path applies). - -## `resolve_tie_phase_covers_all_arms` - -BEHAVIOR 2 — phase-tie default (`resolve_fmts_key_map` line ~936). All four -arms of the even/odd clean-count decision. - -## `assert_gapless` - -Assert `forensic` + `fills` together cover every LBA of every extent EXACTLY -once — no gap (a hole would pass ciphertext through as clear) and no -overlap (two keys over one LBA). This is the load-bearing invariant of the -gap-fill. - -## `fill_base_key_gaps_is_gapless_over_every_extent` - -BEHAVIOR 3 — gap-fill range arithmetic (`resolve_fmts_key_map` line ~1005). -Exhaustive: no segments, mid-extent, at-start, at-end, adjacent segments, -and multi-extent. Each asserts the EXACT fills AND gaplessness over every -extent — an off-by-one that leaves a hole flips this to FAIL. - -## `encrypted_clean_unit` - -Build a 6144-byte aligned unit of CLEAN MPEG-TS (sync `0x47` + non-zero -payload in packets 1.., packet 0 is the clear seed) then AACS-encrypt it -under `key`. Decrypting under the SAME key restores clean TS (`is_clean` → -true); decrypting under any other key yields garbage. - -## `probe_index_phase_all_faults_is_read_fault_not_wrong_key` - -A probe whose EVERY read faults (`read` returns `None`) must classify as -`IndexProbe::ReadFault`, NOT `IndexProbe::WrongKey` — a transient live-drive -read fault while probing must not be read as a missing key (which the -caller turns into a rip-aborting `FmtsKeyMissing`). - -Mutation: reverting to the no-fallback single-segment probe (i.e. treating -even==odd==0 as unconditional `FmtsKeyMissing` regardless of whether any -read succeeded) makes this return `WrongKey` → the assert fails. - -## `probe_index_phase_reads_succeed_but_no_clean_phase_is_wrong_key` - -Reads SUCCEED but decrypt to NEITHER clean parity (ciphertext under a key we -do NOT hold) → `IndexProbe::WrongKey`. This is the genuine-missing-key path -the caller MUST keep as a hard `FmtsKeyMissing`. - -## `probe_index_phase_falls_through_faulting_segment_to_next` - -Read-fault TOLERANCE across segments: the first same-index segment faults on -every read, but a SECOND same-index segment decrypts clean → the probe must -fall through to it and resolve a phase (mirrors the anchor loop's -multi-segment retry). A single-`.find` probe would have stopped at the -faulting first segment. - -## `CipherSource` - -A SectorSource that tiles a fixed 6144-byte ciphertext unit across each -registered extent range (`(start_lba, end_lba, unit)`), zeros elsewhere. -Every 3-sector aligned-unit read inside a range returns the same ciphertext, -so `sample_units` collects real encrypted content for `pick`/fetch to run -on. Low LBAs are zero, so `udf::read_filesystem` fails → the FMTS branch -returns `Ok(None)` and the multi-CPS path is exercised. - -## `resolve_mux_key_map_multi_cps_pick_selects_correct_index` - -`pick()` must select the pool index of the key that actually opens the -extent's real ciphertext — index 2 here, NOT 0. Feeds units encrypted under -the third pool key through the live multi-CPS path. - -Mutation: `pick` hard-returning `Some(0)` keys the extent to 0 → this assert -(Some(2)) fails. - -## `resolve_mux_key_map_multi_cps_fail_loud_on_absent_key` - -Fail-loud: a sample that decrypts clean under NO held key and NO fetched key -(fetch = None) must surface `Error::DecryptFailed`, never silently key the -extent to a neighbour's (wrong) index. - -Mutation: dropping the `None => Err(DecryptFailed)` guard (e.g. falling back -to `last_idx`) returns `Ok` → this `expect_err` fails. - -## `resolve_mux_key_map_multi_cps_fetch_recovers_missing_key` - -KeyFetch cold path: the pool is missing the extent's key, but the injected -`KeyFetch::unit_keys` returns it from the failing samples → the extent -resolves to the newly-appended pool index (2) and the map succeeds. Proves -the on-miss fetch+re-pick branch runs end to end. - -## `CountingCipherSource` - -`CipherSource` plus a counter of CONTENT reads (`lba >= CONTENT_LBA_FLOOR`) — -the `sample_units` probes whose cost this cache exists to remove. Low-LBA -UDF metadata probes are excluded so the counts speak only about extent -sampling. - -## `multi_cps_shared_extent_is_served_from_cache_not_resampled` - -A disc's playlists overwhelmingly share clips, and the multi-CPS path issues -8 random 6144-byte reads per extent. The SECOND title over the same extent -must cost ZERO further reads and resolve the SAME index; a DIFFERENT extent -must still be sampled and get its own index. - -Mutation: dropping the `cache.get` short-circuit re-samples → the -zero-further-reads assert fails. Caching the wrong index (e.g. inserting -`last_idx`) breaks the same-index asserts. - -## `multi_cps_cache_hit_matches_a_full_recompute` - -The cached index must be exactly what a full recompute produces: resolve the -same title twice, once through a warm shared cache and once through a cold -one (a real re-read), and compare the maps range for range. - -Mutation: caching under a key that ignores `start_lba`/`sector_count`, or -storing anything but `pick`'s index, diverges here. - -## `multi_cps_inherited_index_is_not_cached` - -An extent with no sampleable encrypted units inherits the PRECEDING extent's -index — per-title state, not a property of the extent — so it must never be -memoised. Title A reaches the clear extent carrying index 2, title B carries -index 1: B's clear extent must key to 1, not to A's cached 2. - -Mutation: caching the `samples.is_empty() => last_idx` arm makes B's clear -extent resolve to 2 and this fails. - -## `multi_cps_failed_extent_is_not_cached` - -A fail-loud extent (real ciphertext no key opens) must not be memoised: a -later retry, after a key source banked the missing key, has to re-sample and -succeed rather than inherit the earlier failure. - -Mutation: caching before the `None => Err(DecryptFailed)` arm (or caching -the error) leaves the retry unable to resolve. - -## `FaultSource` - -A SectorSource whose every read is a transient I/O fault (`DiscRead`), -modelling a marginal live drive stalling while `resolve_fmts_key_map` probes -the UDF metadata / segment table. - -## `resolve_fmts_key_map_read_fault_propagates` - -A transient `DiscRead` fault while reading the UDF metadata for the segment -table must PROPAGATE (fail loud / retryable), NOT be swallowed into the -not-FMTS `Ok(None)` fall-through — otherwise a marginal AACS 2.1 disc would -silently drop its forensic content under a base-Unit-Key-only map and the -mux would report success. - -Mutation: revert the read_filesystem arm to `let Ok(udf) = ... else { return -Ok(None) }` → this returns `Ok(None)` and the assert fails. - -## `resolve_fmts_key_map_not_udf_is_clean_none` - -A reader whose bytes are structurally NOT a UDF disc (all zeros → no AVDP at -sector 256 → `UdfNotFilesystem`) is genuinely not FMTS: it must map to the -clean `Ok(None)` negative, NOT fail loud. Guards against Fix 1 over-reaching -and rejecting benign non-FMTS discs. - -## `fmts_disc_with_extra_records` - -The same synthetic disc with extra records appended to -`IndividualSegment.tbl` ONLY — the content region is laid out from -`fmts_segments` exactly as before, so the anchor and phase probes still -resolve normally and the extra records are seen purely by the range -builder. That is what isolates each of its "this record does not map" arms. - -## `FmtsDisc::rebuild_meta` / `with_forensic_clip` - -`clip = false` drops `BDMV/STREAM/00001.fmts` from the tree: an FMTS disc -(the table is there) whose forensic clip cannot be identified, so the -segment SPNs have no defensible anchor. `rebuild_meta` returns just the UDF -metadata image, with `tbl_segs` as the segment table and the forensic clip -present — see `fmts_disc_with_extra_records`. - -## `FmtsDisc::with_second_cps_unit` - -The same disc, plus a second CPS unit occupying the extent at -`FMTS_CPS2_LBA` — a disc whose `Unit_Key_RO.inf` carries two base Unit Keys, -which is what makes "the base key" ambiguous. - -## `FmtsDisc::unit_at` - -The 6144-byte ciphertext of the aligned unit starting at clip byte -`unit_byte`: inside a segment, EVEN units carry that index's content and ODD -units the alternate variant; outside, ordinary base-Unit-Key content. - -## `counting_fmts_fetch` - -A `KeyFetch` whose `fmts_indexes` counts its calls and behaves like the real -service: it replies with the disc's COMPLETE ordered index-key set only for -a genuine index-1 anchor batch (one that opens under index key 1), and empty -otherwise. `unit_keys` is never used on this path. - -## `fmts_two_cps_title` - -A play-all title over BOTH CPS units: the forensic clip (CPS unit 1) then -the second unit's extent (CPS unit 2). - -## `fmts_two_cps_keys` - -The disc's two BASE CPS Unit Keys, in `Unit_Key_RO.inf` order — the pool an -AACS 2.1 disc with two CPS units is resolved with. - -## `fmts_gap_fill_uses_each_lbas_own_cps_unit_key_not_pool_slot_zero` - -On an FMTS (AACS 2.1) disc the non-forensic gap fill used to hardcode pool -slot 0 as "the" base Unit Key. On a disc carrying MORE THAN ONE base CPS -Unit Key in `Unit_Key_RO.inf` that is simply the first CPS unit's key, so -every content LBA outside a forensic segment in any OTHER CPS unit was -keyed with the wrong key. - -It does not fail loudly: the mapped decrypt runs the wrong key over those -units and emits garbage plaintext with `lost_bytes == 0`. And -`resolve_mux_key_map_cached` calls the FMTS resolver BEFORE the -`single_base_key_slot` short-circuit, so the guard that makes slot 0 correct -on a single-CPS disc is never consulted on this path. - -The title spans both CPS units; the second extent's every unit is encrypted -under the SECOND base key (pool slot 1). Mutation: pinning the gap fill back -to slot 0 fails the second extent's asserts, and pinning it to slot 1 fails -the first extent's. - -## `fmts_index_keys_resolved_once_per_disc_not_once_per_title` - -The defect this fixes: `resolve_content_key_map` resolves EVERY title, and -the FMTS branch used to re-walk the UDF filesystem, re-run the anchor probe, -re-run the 2-index phase probe AND re-ask the key service — once per -playlist — for an answer that is a property of the DISC. N titles must cost -ONE UDF walk, ONE index probe and ONE `fmts_indexes` call. - -Mutations, and which assert kills each: -* `if table.is_none()` → `if true` (never memoise the segment table): the - `meta_reads` assert fails (the walk repeats per title). -* removing the `memo.get(&ek)` short-circuit: the `probe_reads` AND the - `fmts_indexes` call-count asserts both fail. -* `if probed.all_phases_definite` → `if false` (never insert): same two. - -## `fmts_memoised_map_equals_per_title_recomputation` - -Result-identity, proven rather than assumed: the same three titles resolved -with a SHARED per-disc memo must produce the same maps — and leave the key -pool in the same state — as resolving each with a FRESH memo (i.e. the -unmemoised per-title recomputation). - -Mutation: key the FMTS memo on something NOT title-invariant (e.g. drop the -extent list from `extent_key` and key on `format` alone) → title 3's -differing extents are served the wrong answer and the range comparison -fails. - -## `fmts_different_extent_list_is_a_miss_and_reprobes` - -A DIFFERENT extent list is a genuine miss: the extent list is the only -per-title input to the probes, so it is IN the memo key and a title with a -different one must re-probe rather than inherit. - -Mutation: drop `title.extents` from `extent_key` → the second title hits -and the `probe_reads` / call-count asserts fail. - -## `fmts_read_faulted_phase_is_not_memoised` - -A phase probe that READ-FAULTED on every segment of an index leaves that -index defaulted to `Phase::All` — degraded but complete. That is a property -of a transient live-drive fault, NOT of the title's extents, so it must NOT -be memoised: caching it would spread one bad read across every remaining -playlist. The next title must re-probe. - -Mutation: memoise unconditionally (drop the `all_phases_definite` guard) → -the second title is served from cache and both the `probe_reads` and the -key-service call-count asserts fail. - -## `non_fmts_disc_walks_the_filesystem_once_for_every_title` - -The UDF walk that decides whether a disc is FMTS at all runs on EVERY disc, -FMTS or not. On a plain (non-UDF / non-FMTS) BD it must be attempted ONCE -for the whole disc, not once per playlist — ~35 low-LBA single-sector reads -reached by a full-stroke seek back from wherever the last title's content -sampling left the head. - -Mutation: `if table.is_none()` → `if true` → the second title re-reads the -metadata and the `meta` assert fails. - -## `fmts_memoised_title_still_honors_halt` - -Halt must stay responsive even when both FMTS memos are warm and a title -does no I/O at all: `resolve_content_key_map` polls nothing itself, so the -entry check inside the FMTS branch is the only cancellation point a -fully-memoised title reaches. - -Mutation: remove the `check_halt()?` at the top of `resolve_fmts_key_map` → -the cancelled second title returns `Ok(map)` and `expect_err` fails. - -## `single_cps_short_circuit_survives_a_forensic_title_resolving_first` - -The single-CPS short-circuit must depend on the number of BASE CPS Unit -Keys, NOT on the length of the whole pool — which the FMTS resolver APPENDS -its forensic index keys to. Before the fix, a disc whose first-resolved -playlist was forensic left the shared pool at `1 + n_index`, so every LATER -title (single-CPS, non-forensic) missed the short-circuit and took the -multi-CPS sampling path: 8 random 6144-byte reads per extent, and a -`DecryptFailed` abort of the WHOLE-disc key map for any extent no pooled -key opened. That made the result depend on the ORDER titles resolve in, -contradicting the order-independence `resolve_mux_key_map_cached` -documents. - -Mutation: count the whole pool (`unit_keys.len() == 1`) → the menu title -samples its extent and the `probe_reads` assert fails. - -## `forensic_segments_anchor_to_the_forensic_clip_not_the_titles_first_extent` - -Forensic segment SPNs live in the FORENSIC FEATURE CLIP's byte space, so -mapping them through the TITLE's extent list is wrong for any playlist whose -extents do not begin with that clip. On a play-all playlist ordered -[trailer, forensic feature] the old code treated clip byte 0 as the -trailer's first sector: every segment landed 300 sectors early, the anchor -probe sampled the TRAILER (so the key service never anchored → -`FmtsKeyMissing` aborted the whole disc), and had it anchored, the index -keys would have been applied to non-forensic sectors — silent garble with -no error at all. - -Mutation: anchor the byte space on `title.extents` again → the resolve -either errors (no anchor) or maps segment 1 to 10_000 instead of 10_300. - -## `unidentifiable_forensic_clip_fails_loud_rather_than_guessing_an_anchor` - -The anchor is only sound because the forensic clip is IDENTIFIED. A disc -that carries a non-empty `IndividualSegment.tbl` but no `BDMV/STREAM/*.fmts` -gives the SPNs no defensible byte space, so the resolve must fail LOUD -(`FmtsKeyMissing`, retryable) rather than fall back to a title's extent list -and map forensic index keys onto whatever clip happens to be first — which -produces silently garbled output with no error at all. - -## `single_base_key_slot_counts_cps_units_not_banked_forensic_keys` - -The base-CPS count that drives the single-CPS short-circuit, in isolation: -the forensic index keys the FMTS resolver appends (tagged -`FMTS_POOL_TAG_BASE + n`) are NOT CPS units, and a genuine second CPS unit -is. - -Mutation: drop the tag filter → the first case returns `None` and its -assert fails. - -## `fill_base_key_gaps_sorts_cuts_that_arrive_in_table_order_not_lba_order` - -The forensic ranges reach `fill_base_key_gaps` in `IndividualSegment.tbl` -RECORD order, which is not LBA order — the table is a list of segments, and -nothing in `aacs::segment` sorts it. The gap walk is a single forward sweep -(`cur = cur.max(ce)`), so it is only correct on cuts in ascending order; -that is what `c.sort_unstable()` is for. - -Every existing gap-fill case happens to pass its cuts already sorted, so the -sort is unconstrained by them. Here the cuts arrive REVERSED: without the -sort the sweep takes the high cut first, jumps `cur` past it, then discards -the low cut as "already behind" — and emits a base-key fill straight over -the low forensic segment. That is the silent-wrong-key shape: the forensic -LBAs end up in TWO ranges, one of them keyed with the base Unit Key. - -## `RecordingSource` - -A `SectorSource` that records every `(lba, count)` it is asked for and -serves a caller-chosen aligned unit, so the probe SPREAD of -`sample_encrypted_units` is observable directly. - -## `sample_encrypted_units_probes_eight_aligned_units_spread_across_the_extent` - -`sample_encrypted_units` is the evidence every CPS-unit decision rests on, -so WHICH units it reads matters: 8 probes at `total * p / 9` for p in 1..=8, -each a whole aligned unit (3 sectors) measured from the extent's own start. - -Pinned exactly. A probe count, a divisor or a `p` range that drifts moves -the sample set — clustering probes at one end of a 20-minute clip, where an -authored-bad or padding region can make an extent look unopenable — and -each of those is an independently reachable mutation of this arithmetic. - -## `sample_encrypted_units_drops_clear_units_and_reads_nothing_below_one_unit` - -Only genuinely AACS-encrypted units come back — that is the whole contract -that lets a caller treat a decrypt-to-clean as proof of the key. A clear -(unencrypted) extent yields NO samples, which is what makes `pick_pool_slot` -return `None` and the caller inherit rather than fail loud. - -## `pick_pool_slot_honours_the_caller_s_slot_list_and_its_order` - -`pick_pool_slot` answers "which of THESE slots, in THIS order, opens the -extent" — and its two callers pass different slot lists for a reason: the -multi-CPS path offers the whole pool, the FMTS gap fill only the BASE keys -(offering a forensic index key there would key a whole extent with it). - -So both the RESTRICTION and the ORDER are load-bearing, and neither is -implied by "some slot matched". Two samples open under two different pool -slots here, so the answer is decided purely by which slot the caller listed -first — a `find` that ignored `slots` order, or that scanned the pool -instead, would return the same value for both directions. - -## `extents_overlap_is_half_open_at_both_ends` - -Extents are `[start_lba, start_lba + sector_count)` — half open. This -decides whether a title "reads the forensic clip", i.e. whether -`resolve_fmts_key_map` resolves index keys at all or returns `Ok(None)` and -leaves the title on the base-Unit-Key path. - -Both directions are wrong in a way that does not fail loudly: an inclusive -end makes a title that merely ABUTS the forensic clip resolve (and pay a -key-service round trip) for content it does not read, while a stricter test -makes a title that shares exactly one sector fall through to a -base-key-only map and silently garble its forensic units. - -## `fmts_resolve_err_with` - -Resolve the synthetic FMTS disc with `extra` bogus records appended to its -segment table, and return the error text. - -## `fmts_baseline_table_resolves_so_the_unmappable_record_tests_mean_something` - -Control: the SAME resolve with no extra records succeeds. Without this the -four tests below could be passing for any reason at all — a fixture that -never reaches the range builder would `expect_err` just as happily. - -## `fmts_inverted_segment_record_aborts_rather_than_base_keying_the_hole` - -Arm 1 — an INVERTED record (`start_spn > end_spn`). `end_byte - 1 - -start_byte` would underflow, so the record is refused; the tally is what -turns that refusal into a loud failure instead of a base-keyed hole. - -## `fmts_record_with_no_index_key_aborts_rather_than_base_keying_the_hole` - -Arm 2 — a record whose forensic INDEX has no key. The synthetic source -returns two index keys, so tags 1 and 2 have pool slots and tag 3 has none. -On a real disc this is a table that outruns the key set the service -returned — precisely the case where guessing a key is worst. - -## `fmts_record_running_past_the_clips_end_aborts_rather_than_base_keying_it` - -Arm 3 — a record whose START is addressable within the clip (so -`filter_addressable_segments` keeps it) but whose END runs past the clip's -last byte. Only the second `clip_byte_to_lba` fails, which is why the -filter upstream cannot stand in for this check. - -## `fmts_record_off_the_aligned_unit_grid_aborts_rather_than_spanning_wrongly` - -Arm 4 — a record whose LBA span is not one contiguous run: `b - a` counts -SECTOR crossings while `(end_byte - 1 - start_byte) / 2048` counts the -span's own length in sectors, and the two disagree exactly when the record -is not aligned to the aligned-unit grid the forensic interleave is defined -on (or when it straddles a clip extent boundary). - -A structurally valid forensic segment cannot hit this: the interleave is -per 6144-byte aligned unit, so a real record starts and ends on a -32-packet boundary and the two counts agree. `start_spn = 10` does not — -clip byte 1920 is mid-sector — so the span is refused. - -## `multi_cps_cache_hit_still_feeds_the_next_extents_inheritance` - -The multi-CPS loop's inheritance chain has to run THROUGH a cache hit. An -extent served from the memo never re-samples, so its index reaches the next -extent only because the hit arm carries it into `last_idx`; without that, a -following extent with no sampleable ciphertext inherits whatever the loop -started at (slot 0) instead of its neighbour's key. - -That is silent: an unsampleable extent produces no error either way, so the -map simply keys those LBAs to the wrong CPS unit and the mux decrypts them -to garbage with `lost_bytes == 0`. The existing shared-extent test resolves -a SINGLE-extent title through the cache, so nothing downstream of the hit -is observed; here the clear extent is deliberately placed AFTER the hit. - -## `fmts_multi_cps_gap_fill_carries_the_preceding_extents_cps_unit_to_a_clear_tail` - -The FMTS gap fill on a MULTI-CPS disc runs its own extent loop, with its own -inheritance chain (`base_slot_for_extent`'s `last_idx`). A title whose last -extent has no sampleable ciphertext — a clear/nav tail, which is exactly the -case that cannot fail loudly — must take the CPS unit its neighbour is in, -not `base_slots[0]`. - -Slot 0 here is CPS unit 1's key and the neighbour is CPS unit 2, so losing -the carry keys the tail with the wrong unit's key and decrypts it to -garbage with no error at all. - -## `fmts_multi_cps_gap_fill_samples_each_extent_once_per_disc` - -The FMTS gap fill samples extents off the LIVE DRIVE through the same -per-disc `CpsUnitCache` the multi-CPS path uses — 8 random 6144-byte reads -per extent, ~200 ms of seek apiece. Every input to that decision is in the -cache key, so a second title over the same extents must cost ZERO further -content reads. - -The index-key memo alone does not deliver that: it short-circuits the -anchor and phase probes but the gap-fill loop still runs, and on a -multi-CPS disc it re-samples every extent unless `base_slot_for_extent` -banked its verdict. - -## `fmts_stop_during_the_udf_walk_touches_no_content_sector` - -Stop lands while the UDF walk is still running — before the anchor loop. -The anchor loop's own poll must catch it, so the drive is never asked for a -single CONTENT sector. Without that poll the entry poll has already passed -and the next one is inside the phase loop, so the full anchor batch (two -`MIN_SAMPLE_UNITS` phase reads plus a key-service round trip) is issued to -a drive the operator has already stopped. - -## `fmts_stop_during_the_anchor_batch_stops_before_the_phase_probes` - -Stop lands with the drive already working, during the anchor batch. The -PHASE loop's poll must catch it before probing index 1's parity — -otherwise every index in the set is probed (`MAX_ANCHOR_ATTEMPTS` segments -× `MIN_SAMPLE_UNITS` × 2 parities each) after the operator said stop. diff --git a/docs/resync.md b/docs/resync.md deleted file mode 100644 index b02d1caf..00000000 --- a/docs/resync.md +++ /dev/null @@ -1,44 +0,0 @@ -# `resync` — B1 drop-to-IRAP gate rationale - -## Why drop forward instead of just dropping the lost frame - -When packets are lost, the affected access unit is already dropped at the TS -layer (the assembler drops the partial PES on the continuity gap). But for -INTER-CODED video the frames that follow reference the lost frame (and each -other) until the next IRAP/IDR keyframe — emitting them makes any decoder -fault on the "missing reference / non-existing PPS" condition and visibly -break decode. So after a gap on a video track this gate DROPS FORWARD to the -keyframe and resumes cleanly there. The gap rounds up to (at most) one GOP — -the price of never emitting a dangling reference; it is logged. - -## Why this is not simply "audio vs video" - -Most audio and all subtitle frames are independently decodable (each frame -re-inits on its own header), so a gap there costs only the single -already-dropped frame and this gate is a no-op for them (it always admits). -The one exception is TrueHD/MLP, whose predictor + restart state spans access -units: its re-init point is a codec-specific major-sync AU (not a generic -keyframe), so it runs the equivalent drop-forward-to-major-sync inside -`codec::truehd` rather than through this gate. In short: this gate is NOT -keyed on "audio vs video" but on "needs a re-init point after a gap" — video -here, TrueHD in its own parser, everything else genuinely independent. - -## `admit` parameters and behavior - -* `is_video` — inter-coded video track (the only kind with cross-frame - references); `false` for audio/subtitle, which always admit. -* `discontinuity` — this frame's source PES followed a TS continuity gap. -* `keyframe` — this frame is a self-contained IRAP/IDR. - -A non-video track always admits. A video track arms on a discontinuity and -then drops every non-keyframe until (and excluding the drop of) the next -keyframe, which disarms and is emitted. - -## Test: `a_resolved_gap_still_reports_its_dropped_frames` - -`dropped` is per-run and `dropped_total` is cumulative, and only the second -can report loss. A gap that RESOLVES — the common case — disarms the gate at -the next keyframe and zeroes `dropped`. Anything reading that counter -afterwards sees nothing happened, which is how concealed video loss reached -no caller: the only EOF warning fired for gates STILL armed, i.e. exactly the -gaps that did not resolve. diff --git a/docs/rip-recovery.md b/docs/rip-recovery.md deleted file mode 100644 index 2556f814..00000000 --- a/docs/rip-recovery.md +++ /dev/null @@ -1,98 +0,0 @@ -# Rip recovery — what libfreemkv owns - -**Recovery strategy moved OUT of this crate in 1.6.0.** The forward sweep, the -targeted retry pass, the ddrescue mapfile, damage classification and the -multipass loop now live in the **`freemkv-engine`** crate as -`freemkv_engine::recovery::{copy, sweep, patch}`. The dependency runs -engine → libfreemkv, so this crate cannot call into the engine; front-ends -(`freemkv` CLI, autorip) get recovery from the engine directly. - -What stayed here are the two layers underneath the strategy: the single-shot -read primitive, and the in-stream request-size adaptation that sits in front of -it. This document covers those, plus the design constraints they encode — the -constraints are the reason the strategy above them looks the way it does, so -they belong with the code that enforces them. - -For the strategy itself — damage-jump thresholds, pass ordering, mapfile status -state machine, wedge detection — read `freemkv-engine/src/recovery/`. - -| Layer | Where it lives | What it does | -|-------|---------------|--------------| -| 1 — Bad-range retry | **`freemkv-engine`** (`recovery::patch`) | Re-reads non-`+` ranges with the long timeout. Idempotent; caller invokes N times. | -| 2 — Single-shot primitive | `Drive::read` in `src/drive/mod.rs` | One CDB, one timeout, one result. No inline retries, no SCSI reset. | -| 3 — In-loop request adaptation | `DiscStream::fill_extents` in `src/mux/disc.rs` | Halves the batch on failure, retries at the same LBA, walks back up on a clean-read streak. | - -Layer 2 also translates drive facts: [`SenseFamily`](../src/scsi/mod.rs) -classifies SCSI sense data into the categories the engine's strategy routes on -(marginal vs. hardware vs. not-ready). Getting that classification wrong -silently misroutes recovery, which is why it lives next to the transport rather -than in the strategy. - -Layer 3 runs inside any consumer of `DiscStream` — direct PES pipeline, ISO -playback — without caller involvement, and applies whether or not the engine's -recovery is in play. - -## In-stream — adaptive batch halving (`DiscStream::fill_extents`) - -When a consumer reads a `DiscStream` directly (no ISO intermediate), -`fill_extents` runs an adaptive sizer in front of `Drive::read`: - -1. Try the current preferred batch size (e.g. 32 sectors, one BD ECC block). -2. On failure: halve the batch and retry at the same LBA. Emit - `EventKind::BatchSizeChanged { reason: Shrunk }`. -3. On a clean-read streak: probe back up toward the preferred size. Emit - `EventKind::BatchSizeChanged { reason: Probed }`. -4. If a single-sector read fails: skip (zero-fill, emit - `EventKind::SectorSkipped`) when `skip_errors` is set, otherwise return - `Err(DiscRead)`. - -This exists so a transient single-sector glitch inside a 32-sector batch can be -isolated and read individually without the caller implementing retry logic. See -[`src/event.rs`](../src/event.rs) for the emitted events. - -## Design choices - -These are constraints on the read path, enforced here and relied on by the -engine's strategy. - -**`Drive::read` is single-shot.** No inline retry phases, no SCSI reset, no -eject cycle. The `recovery` flag controls only the per-CDB timeout (10 s vs. -60 s); on any failure it returns `Err(DiscRead)` immediately. Inline recovery -(5× gentle retry → close + SCSI reset + reopen → 5× more) was removed in -0.13.6. See the stop-wedge postmortem (2026-04-25) for rationale: the inline -reset on the LG BU40N (Initio USB-SATA bridge) wedged drive firmware below the -bridge without ever recovering a sector, and the gentle-retry phase produced -long stretches of 0 KB/s with nothing to show for it. Recovery responsibility -is layered instead: layer 1 handles ranges, layer 3 handles request size, -neither touches the wedge-prone reset path. - -**No `MODE SELECT` to disable drive retries.** Neither ddrescue nor any -consumer ripper does this. Drive firmware has access to raw analog signal, -laser power control and drive-specific ECC tuning that userspace cannot -replicate — disabling it throws away recovery headroom on marginal sectors. The -fast pass fails quickly via short SG_IO timeouts and lets the firmware work the -long timeout during retry. - -**No SCSI reset from any read path.** There is no reset escape hatch on -`Drive` at all: the `SG_SCSI_RESET` ioctl and STOP/START UNIT escalation went in -0.13.6, the macOS reset (always a no-op) was removed entirely, and the -top-level `scsi::reset()` wrappers went with their last callers. The only -remaining reset is a Windows-specific device-level helper in -[`src/scsi/windows.rs`](../src/scsi/windows.rs), never reached from a read. - -**ISO intermediate, even for single-pass.** The engine's Pass 1 always writes -an ISO, and the mux stage reads it back via `FileSectorSource`. For a -no-retry rip this costs a few minutes but buys resumability across crashes, -re-muxability without re-ripping, and a persistent forensic artifact. Callers -who need pure speed can bypass it with `DiscStream::new(Box::new(drive), …)` — -nothing forbids it, and layer 3 still applies there. - -## References - -- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html) -- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html) -- Recovery strategy and mapfile: `freemkv-engine/src/recovery/` -- In this crate: [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`), - [`src/scsi/mod.rs`](../src/scsi/mod.rs) (`SenseFamily`), - [`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`), - [`src/event.rs`](../src/event.rs) (progress events). diff --git a/docs/scsi-error-decoding.md b/docs/scsi-error-decoding.md deleted file mode 100644 index 43059c5b..00000000 --- a/docs/scsi-error-decoding.md +++ /dev/null @@ -1,42 +0,0 @@ -# SCSI error-decoding test contract - -Background for `tests/scsi_error_decoding.rs`. - -v0.13.20 rewrote `scsi/linux.rs` to a synchronous blocking SG_IO and -consolidated sense parsing into the `parse_sense` helper that every -platform backend now shares. v0.13.23 replaced the `Error::ScsiError` -flat-fields shape with `{ opcode, status, sense: Option<ScsiSense> }` -so callers can route on structured sense data (key + ASC + ASCQ) via -`Error::scsi_sense` / `Error::is_marginal_read` / `ScsiSense::is_*`. - -The actual `ioctl(SG_IO, ...)` call is impossible to mock without a -kernel, so libc shims are deliberately avoided here. These tests -therefore pin the *contract* every backend must satisfy via a mock -`ScsiTransport`: - -1. Healthy result -> `Ok(ScsiResult { bytes_transferred = data.len() - resid })`. -2. Transport-level failure (no SCSI status delivered: kernel - timeout, USB bridge wedge, IOKit service error) -> - `Error::ScsiError { status: SCSI_STATUS_TRANSPORT_FAILURE, sense: None }`. - `Error::is_scsi_transport_failure()` returns `true`. Used by - `drive_has_disc` to detect the wedge signature. -3. SCSI-level failure (drive replied CHECK CONDITION with sense) -> - `Error::ScsiError { status: 0x02, sense: Some(ScsiSense {...}) }` - with the parsed key/ASC/ASCQ. -4. `Error::is_marginal_read()` is `true` for MEDIUM ERROR / - ABORTED COMMAND / RECOVERED ERROR / NO SENSE; `false` for - HARDWARE / DATA PROTECT / UNIT ATTENTION / NOT READY / ILLEGAL - REQUEST and for transport failures. - -Inline `parse_sense_tests` in `src/scsi/mod.rs` cover the pure parse -logic (descriptor 0x72/0x73 vs fixed 0x70/0x71, short-buffer, VALID -bit masking, unknown response codes, ASC/ASCQ offsets); this file -covers the consumer side -- a real transport feeding a real Error -variant to a real call site (`scsi::inquiry`). - -## MockTransport outcomes - -Outcomes mirror what each backend's `execute()` should produce after -the v0.13.23 sense plumbing: `Option<ScsiSense>` carrying the full -SPC-4 triple for drive-reported failures, `None` for transport-level -failures. diff --git a/docs/scsi-linux.md b/docs/scsi-linux.md deleted file mode 100644 index 32296c7b..00000000 --- a/docs/scsi-linux.md +++ /dev/null @@ -1,49 +0,0 @@ -# Linux SCSI transport (`src/scsi/linux.rs`) - -## Kernel error-handling ladder - -No userspace abort, no fd close+reopen, no SG_SCSI_RESET escalation — the -kernel SCSI mid-layer's `scsi_eh.rst` ladder (ABORT TASK -> LUN RESET -> -BUS RESET -> HOST RESET) runs internally when `hdr.timeout` expires, and -by the time the ioctl returns the kernel has already done what it can. - -## Why one synchronous ioctl - -This matches established practice for optical/SCSI I/O: a single -synchronous ioctl with a bounded per-command timeout (commonly in the -8-60 s range), consistent with the Linux kernel default for SCSI block -devices (30 s `/sys/.../timeout`). - -## History: the async design it replaced - -Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout + -bg reopen` pattern. That abandoned slow-but-alive commands faster than -the drive could drain its internal queue, deepening the wedge -pattern on the LG BU40N. Reverted in 0.13.20. - -## `execute()` details - -One syscall: `ioctl(fd, SG_IO, &hdr)`. The kernel honors `hdr.timeout` -and runs its own ABORT TASK -> LUN RESET -> BUS RESET -> HOST RESET -escalation if the device times out (per `Documentation/scsi/scsi_eh.rst`). -By the time this returns, the kernel has done its recovery work. - -Errors surfaced to the caller: - -- ioctl returned -1 -> `Error::IoError` (kernel-level failure) -- `hdr.host_status` != 0 OR `(hdr.driver_status & ~DRIVER_SENSE)` != 0 - -> `Error::ScsiError { status: 0xFF, sense_key: 0, asc: 0, ascq: 0 }` - (real transport-layer failure: kernel timeout, bridge wedge, bus error) -- `hdr.status` != 0 (typically `0x02` CHECK CONDITION) -> - `Error::ScsiError { status, sense_key, asc, ascq }` carrying the - drive's full SPC-4 sense triple. Callers route on - `is_medium_error()`, `is_unit_attention()`, etc. - -SG's `DRIVER_SENSE` (0x08) bit indicates *sense data is attached* — it's -set on every CHECK CONDITION reply. It is **not** a transport failure; -pre-0.13.23 we conflated it with one and silently lost every -drive-reported error reason. The mask in the transport-error check is -the fix. - -Caller's `data` buffer is mutated only on success; partial transfers -are reported via `bytes_transferred = data.len() - resid`. diff --git a/docs/scsi-macos.md b/docs/scsi-macos.md deleted file mode 100644 index c2f1fd91..00000000 --- a/docs/scsi-macos.md +++ /dev/null @@ -1,83 +0,0 @@ -# macOS SCSI transport (`src/scsi/macos.rs`) - -## Open sequence (C shim, `macos_shim.c`) - -`MacScsiTransport::open` drives all CDBs (INQUIRY, READ, REPORT KEY, etc.) -through `SCSITaskDeviceInterface::ExecuteTaskSync` — 1:1 with the Linux -SG_IO backend. Getting there involves the shim doing: - -1. `diskutil unmountDisk force` on the target device only -2. Find `IOBDServices` matching the requested BSD name (walks IOKit - registry: IOBDServices -> IOBDBlockStorageDriver -> IOMedia -> BSD Name) -3. Create `MMCDeviceInterface` -> `SCSITaskDeviceInterface` -4. `ObtainExclusiveAccess` -5. Raw CDB dispatch via `CreateSCSITask` + `ExecuteTaskSync` - -## Single-instance ownership (`OPEN` flag) - -The C shim uses one global IOKit handle (`g_handle`), so only one -`MacScsiTransport` may exist at a time: a second `open()` would share -that handle, and the first `drop()` would tear it down out from under -the other. The `OPEN` `AtomicBool` enforces single-instance ownership, -independent of the shim's own state. - -## `map_shim_open_error` - -Maps a `shim_open_exclusive` failure sentinel (a negative return code, -NOT an `IOReturn`) to its typed `Error` variant. Pulled out of `open` -as its own callable predicate so the mapping — otherwise reachable only -through a real IOKit FFI call — can be pinned by a test: collapsing -`-4..=-2` or `-5` into the `DeviceNotFound` catch-all would silently -turn "another process holds the drive" into "no such drive", or "the -IOKit plugin chain failed" into the same. - -## Drive enumeration and the media-presence probe - -`list_drives` uses the IOKit registry directly via `shim_list_drives` — -no exclusive access, no SCSI commands, no unmounts. It enumerates -`IOBDServices` even when no `IOMedia` child exists. With media, the -returned path remains `/dev/diskN`; with an empty tray it uses the opaque -`ioreg:<registry-id>` selector so the service can still be opened directly. -That selector is an internal list-then-open handle, not a filesystem path. - -`drive_has_disc` is documented (`crate::scsi::drive_has_disc`) as the -cheap, side-effect-free "is there a disc?" question, suitable for a -poll-loop tick. The Linux backend honours that: `open(O_RDWR|O_NONBLOCK)` -+ one TEST UNIT READY, no exclusive access, no unmount. The Windows -backend likewise opens a shared handle and issues one TUR. - -macOS could not do the same: `MacScsiTransport::open` is the FULL -exclusive-transport path above, whose first act is `diskutil unmountDisk -force` on the target device. So a probe implemented via `open` would -force-unmount the user's disc — and on every poll tick, taking (and -dropping) exclusive access each time. - -Instead `drive_has_disc` answers via `shim_media_present`, the IOKit -registry only: steps 1-5 above are the *transport* open path, and this -probe must not run any of them. For a BSD disk path, the registry looks -for its matching `IOMedia`; for an `ioreg:<id>` selector, it resolves the -drive service and checks whether that service currently has media. -No SCSI command is issued, which is why no timeout parameter is -involved. - -Trade-off, stated plainly: this reports what the OS has *enumerated*, -so a disc that is inserted but still spinning up (no IOMedia published -yet) reads as absent for the moment the enumeration takes — the same -window in which a TUR would answer "not ready" and this function's -contract already maps to `Ok(false)`. - -## Regression test: `presence_probe_does_not_open_a_transport` - -`drive_has_disc` used to be implemented by constructing a FULL exclusive -transport, whose first act is `diskutil unmountDisk force` on the target -device followed by an unconditional `usleep(500000)` — so the probe -force-unmounted the user's disc, on every poll tick. - -Two observables separate the registry probe from the transport open, -neither of which needs an optical drive to be attached: - -1. It ANSWERS. The transport path returned `Err(DeviceNotFound)` here; - the registry path reports "no media" as `Ok(false)`. -2. It is FAST. The transport path's `usleep(500000)` after the spawn is - unconditional, so it could not complete inside this budget even when - the spawn itself failed. diff --git a/docs/scsi-mod.md b/docs/scsi-mod.md deleted file mode 100644 index c812eefe..00000000 --- a/docs/scsi-mod.md +++ /dev/null @@ -1,155 +0,0 @@ -# src/scsi/mod.rs — relocated internal notes - -Overflow detail for internal (non-public) comments in `src/scsi/mod.rs`, -moved here to keep the guard's internal-comment cap. Each section is -pointed to by a short `// See docs/scsi-mod.md — ...` comment at its -original site. - -## READ_TIMEOUT_MS calibration - -10 s is calibrated from live empirical data on an LG BU40N + Initio 1618L -bridge ripping a UHD with marginal sectors: - -- Sustained sequential reads: 3–7 ms -- Cold-start seek + read: up to ~1500 ms -- Successful ECC recovery: 1.6–2.6 sec -- Confirmed unreadable sector: 3.6–8.8 sec (kernel timeout) - -10 s catches every legitimate slow read with comfortable margin and -short-circuits truly bad sectors at ~10 s rather than letting the kernel -mid-layer escalate for 30 s+. - -Pre-0.13.21 this was 1.5 s, which forced the kernel mid-layer to time out -*normal* reads (cold-start often takes ~1.5 s) and run its full ABORT -TASK / LUN RESET / BUS RESET escalation while userspace kept submitting -fresh reads. The Initio bridge couldn't drain the resulting command queue -and entered a wedge state that only physical replug recovered — proven by -the v0.13.18 + v0.13.20 live tests. - -Consumed only by the ripping read path (`drive::read`); gated so a -transport-only build doesn't warn on an unused const. - -## READ_RECOVERY_TIMEOUT_MS history - -Matches `sg_dd`'s 60 s ceiling: long enough that any sector the drive can -recover at all gets the time to do so, short enough that an unresponsive -bus is detected before the per-range watchdog fires. - -In practice failed reads return in 1–4 s (the drive itself gives up on -uncorrectable ECC before the timeout); the 60 s value is a safety -ceiling, not a steady-state cost. - -Historical note (2026-05-08): briefly lowered to 2 s with a 5x inline -retry loop in `freemkv_engine::recovery::patch` to mimic the kernel -`sr_mod` driver's auto-retry pattern. The synthetic logic worked but on -the live drive each "2 s" read paid ~1.5 s of kernel SCSI mid-layer -error escalation on top, so 5x retries took ~17 s per LBA and triggered -MAX_RANGE_SECS after 4 sectors — pushing recovery to 0/22 ranges (worse -than the 0/22 baseline of v0.17.3 single-shot at 60 s, since that at -least visited every range). Reverted; the kernel-auto-retry approach is -being pursued via a `/dev/sr0` pread fallback instead. - -## checked_cdb_len rationale - -Under SPC-4 the opcode's group code (bits 7-5 of byte 0) fixes the CDB -length, so dropping the tail bytes does not produce a shorter form of the -same command — it produces a DIFFERENT command with a different meaning -for the bytes that remain. The drive will usually execute it and return -GOOD status with data for a request the caller never made: a silently -wrong result, on the transport layer everything else in the crate sits -on. - -Lives here, shared by all three platform backends, so the guard cannot -drift per platform (it previously truncated on Linux and Windows while -erroring on macOS — the "works on my platform, not theirs" class). - -An EMPTY CDB is rejected here too. `ScsiTransport` is a public trait, so -an out-of-crate caller can pass one; every backend then either indexes -`cdb[0]` (a panic out of a public API) or hands the driver a zero-length -command descriptor, which under SPC-4 is not a command at all. That -guard used to exist ONLY in the Linux backend — macOS and Windows had -nothing — which is the same per-platform drift this helper exists to -prevent. - -## parse_sense format details - -Handles both response-code formats SPC-4 mandates: - -- **Descriptor format** (response code `0x72` / `0x73`): - - sense key = `sense[1] & 0x0F` - - asc = `sense[2]` - - ascq = `sense[3]` -- **Fixed format** (response code `0x70` / `0x71` and any unknown code - per SPC-4 §4.5.3): - - sense key = `sense[2] & 0x0F` - - asc = `sense[12]` - - ascq = `sense[13]` - -`sb_len_wr` is the number of bytes the transport actually wrote into -`sense`. When the buffer is too short for the relevant fields we return -[`ScsiSense::NONE`] for the missing pieces rather than reading -uninitialised memory. The minimum useful sense reply is 4 bytes -(descriptor, to reach ASCQ at offset 3) or 14 bytes (fixed, to reach -ASC/ASCQ at offsets 12/13). - -Pure function — same parse on every platform backend (Linux SG_IO, macOS -IOKit, Windows SPTI) so a regression here would silently mis-route SCSI -errors on all three OSes simultaneously. - -## is_marginal detail - -- `MEDIUM ERROR` (3) — canonical bad-sector signal -- `NOT READY` (2) — on many drives (notably BU40N), this is the dominant - response for unreadable sectors (ASC 04/3E, 04/01, etc.) -- `ABORTED COMMAND` (B) — transient; retry usually works -- `RECOVERED ERROR` (1) / `NO SENSE` (0) — drive is healthy and either - recovered the data or has no specific fault to report - -## max_transfer_bytes Windows rationale - -The Windows backend overrides the 1 MiB default with the adapter's real -`MaximumTransferLength` (queried via `IOCTL_STORAGE_QUERY_PROPERTY`): a -16 MiB READ that exceeds the adapter limit makes `DeviceIoControl` fail -outright, which freemkv then mis-reads as a transport failure and falls -back to slow, log-spamming tiny reads. Chunking to this limit fixes -that. Linux/macOS keep the 1 MiB default (well within any real -`max_sectors_kb`). - -## drive_has_disc no-recovery rationale - -A single TUR is issued; nothing else. No SCSI bus reset, no USB device -reset, no retry is attempted in-library (the USB-reset escalation was -removed in 0.13.4 after it was shown to deepen rather than clear the -wedge). No SCSI primitive is exposed to outside crates — autorip / -freemkv CLI / bdemu use this single function for the entire "is there a -disc?" decision. - -## scsi module reset history - -A top-level `scsi::reset()` wrapping platform reset in a -thread+recv_timeout used to live here; removed in 0.13.6 along with the -SG_SCSI_RESET / STOP+START UNIT escalation it existed to guard. - -USB-layer recovery (`scsi::usb_reset()`) was rolled back in 0.13.4: on -the LG BU40N the USB reset itself succeeds but firmware below the bridge -stays locked until unplug-replug. See git tag `v0.13.3`. - -The only hardware-touching APIs autorip + freemkv CLI use outside the -rip path: a one-shot `list_drives()` enumeration and a single-TUR -`drive_has_disc(path)`. `Drive::open`/`init()`/`Disc::scan` stay heavy. - -## align_up mask semantics - -`mask` is a *mask*, not a power-of-two alignment value: `0` means "no -alignment requirement" (any address is fine), `1` means 2-byte, `3` means -DWORD (4-byte), `7` means 8-byte, etc. — always one less than the -required alignment. An address is acceptable iff `(addr & mask) == 0`. - -Returns the smallest `addr >= p` with `(addr & mask) == 0`. The standard -branch-free idiom `(p + mask) & !mask` works for any valid (`2^n - 1`) -mask, including `mask == 0` (where it is the identity). - -Lives here, compiled on every platform, so the Windows SPTI bounce -buffer in `windows.rs` can share it and so the arithmetic gets unit -coverage on macOS/Linux CI even though the SPTI path only builds on -Windows. diff --git a/docs/scsi-windows.md b/docs/scsi-windows.md deleted file mode 100644 index c52b03b4..00000000 --- a/docs/scsi-windows.md +++ /dev/null @@ -1,87 +0,0 @@ -# Windows SCSI transport (`src/scsi/windows.rs`) - -## `IOCTL_STORAGE_RESET_DEVICE` derivation - -`IOCTL_STORAGE_RESET_DEVICE` (ntddstor.h) = `CTL_CODE(IOCTL_STORAGE_BASE=0x2D, -0x0401, METHOD_BUFFERED=0, FILE_READ_ACCESS=1)` = -`(0x2D<<16) | (1<<14) | (0x0401<<2) | 0` = `0x002D0000 | 0x4000 | 0x1004` = -`0x002D5004`. - -Two earlier values were wrong: `0x002D1004` (function `0x401` but access -bits cleared) and `0x002DD000` (function `0x400` + R|W access — the -OBSOLETE RESET_BUS code class drivers reject). Both made `DeviceIoControl` -fail `ERROR_INVALID_FUNCTION`, silently skipping the reset. See the -value-regression test at the bottom of the module. - -## `StorageAdapterDescriptor` field layout - -Subset of `STORAGE_ADAPTER_DESCRIPTOR` (winioctl.h) up to and including -`MaximumTransferLength`. The real struct has more trailing fields, but the -driver fills the whole thing and we only read this prefix; reading a -truncated descriptor is the documented usage. Field layout (all the -leading fields are present so the offset of `MaximumTransferLength` is -correct): `Version, Size, MaximumTransferLength, MaximumPhysicalPages, -AlignmentMask: u32 …`. - -`STORAGE_BUS_TYPE` is an `int`-sized enum (4 bytes), not a byte: with the -preceding BOOLEANs filling 20..24, `BusType` sits at offset 24 and the -USHORT version fields follow at 28/30, matching winioctl.h. A previous `u8` -declaration for `BusType` kept the total size at 32 by coincidence but -shifted `BusMajorVersion`/`BusMinorVersion` to offsets 26/28 (vs the SDK's -28/30), so any reader of those fields got wrong values. The layout test -below asserts every offset field-for-field against the SDK. - -## `alignment_mask` / bounce-buffer rationale - -Adapter `AlignmentMask` (`STORAGE_ADAPTER_DESCRIPTOR`, ntddscsi.h / -winioctl.h), queried alongside `max_transfer`. It is a *mask*: `0` (the -common case on USB optical bridges) means the DataBuffer may sit at any -address; `3` means DWORD-aligned, `7` 8-byte, etc. — always one less than -the required alignment. SCSI/SAS HBAs report nonzero masks, and -`IOCTL_SCSI_PASS_THROUGH_DIRECT` rejects a misaligned `DataBuffer` -(`DeviceIoControl` fails → all reads return transport failure / status -0xFF). When set and the caller's buffer is misaligned, `execute()` bounces -through an aligned scratch buffer. - -## `normalize_device_path` duplication - -A near-identical `normalize_path` exists in `drive::windows`. Both are kept -because they live in separate `cfg(windows)` modules that cannot easily -share a helper without introducing cross-module coupling. - -## `query_adapter_descriptor` contract - -Queries the storage adapter descriptor via `IOCTL_STORAGE_QUERY_PROPERTY` / -`StorageAdapterProperty` and returns `(max_transfer_bytes, alignment_mask)`. - -`max_transfer_bytes`: the adapter's `MaximumTransferLength`. On any failure -(IOCTL failed, short reply, or a nonsensical zero) falls back to the -conservative `WINDOWS_MIN_TRANSFER_BYTES`; otherwise clamped up to that -floor. Never 0. - -`alignment_mask`: the adapter's `AlignmentMask` (offset 16 in -`STORAGE_ADAPTER_DESCRIPTOR`). `0` means no alignment requirement. If the -reply is too short to include `AlignmentMask`, returns `0` (no -requirement) — the safe default, since any address satisfies a zero mask -and the descriptor's leading fields are read first regardless. - -## `ScsiPassThroughDirect` layout, cross-checked against SDK - -Cross-checked against the authoritative `SCSI_PASS_THROUGH_DIRECT` in the -Windows SDK `ntddscsi.h`. That struct has **no `#pragma pack`** — it uses -natural alignment — so on 64-bit Windows (LLP64, 8-byte `PVOID`) the -compiler pads `DataBuffer` to offset 24 and the struct is 56 bytes. That is -the layout `DeviceIoControl` expects, and bare `#[repr(C)]` reproduces it. - -Do NOT add `packed(4)`: that yields offset 20 / 48 bytes, which is the -SDK's SEPARATE 32-bit thunk struct `SCSI_PASS_THROUGH_DIRECT32` (`VOID* -POINTER_32 DataBuffer`). Using that 32-bit layout on a 64-bit host malforms -every SPTI ioctl, so INQUIRY fails and drive enumeration returns zero -drives (the rc.4 Windows "no drives detected" regression). - -## `windows_ffi_constants_match_sdk` test rationale - -Each IOCTL is asserted against an INDEPENDENT re-derivation of the -`CTL_CODE` macro (devioctl.h: -`(DeviceType<<16) | (Access<<14) | (Function<<2) | Method`), not just its -literal — so a mistyped constant fails the derivation, not a tautology. diff --git a/docs/sector.md b/docs/sector.md deleted file mode 100644 index 1bd3ca0f..00000000 --- a/docs/sector.md +++ /dev/null @@ -1,47 +0,0 @@ -# src/sector/mod.rs — rationale notes - -## `read_sectors_fua` FUA rationale - -`fua = true` asks the drive to bypass its readahead cache and physically -re-fetch the medium — a Pass-N marginal-sector lever: a cached hit can mask a -*stochastic* sector that would land differently off the platter on each -physical read, so `FuaRetry` re-reads it FUA. It is never blanket-applied to -the bulk path (forcing every sequential read past the cache collapses -streaming throughput ~10x). - -## Test rationale: forwarding-impl coverage (`mod tests`) - -- `set_unit_base_generic` / `read_generic` / `read_fua_generic` / - `set_speed_generic`: a direct call on a `&mut dyn SectorSource` or - `Box<dyn SectorSource>` receiver auto-derefs straight to the vtable and - never touches the `impl SectorSource for Box<dyn SectorSource>` / - `impl SectorSource for &mut (dyn SectorSource + '_)` forwarding bodies. - Routing the call through a generic `S: SectorSource` bound instead forces - monomorphization to go through the forwarding body, so these tests are the - only ones that actually exercise it. - -- `mut_ref_dyn_forwards_read_sectors_to_the_inner_source`: proves the - forwarding impl actually delegates `read_sectors` — passes args through - unchanged, returns the inner source's byte count, and leaves the inner - source's bytes in the caller's buffer. A body that returned a bare `Ok(n)` - without calling through would be a delegating reader that reads nothing and - reports success: the caller sees `Ok` and consumes an untouched buffer. - -- `mut_ref_dyn_forwards_read_sectors_fua_to_the_inner_source`: same, for the - FUA entry point — the forwarder must reach the inner source's FUA method - (not silently downgrade to the plain read, and not fabricate a count), - carrying the `fua` bit through. FUA is the Pass-N lever that re-fetches a - stochastic sector past the drive cache (MMC-6 READ(10) FUA bit); a - forwarder that dropped it would make every FUA retry re-read the same - cached bytes and "confirm" the bad sector. - -- `mut_ref_dyn_forwards_set_speed_to_the_inner_source` / - `boxed_dyn_forwards_set_speed_to_the_inner_source`: `set_speed` hides worse - than the read methods because the trait's own default body is also a - no-op (`fn set_speed(&mut self, _kbs: u16) {}`), so a forwarding impl that - drops the call on the floor compiles, type-checks, and looks exactly like a - source that legitimately has no speed control. The consequence is not a - wrong value but a silently absent one: the recovery path throttles a - struggling drive by lowering its read speed, and a forwarder that swallowed - the call would leave the drive at full speed through the damaged region - while the caller believed it had slowed down. diff --git a/docs/segment-key.md b/docs/segment-key.md deleted file mode 100644 index b581afc3..00000000 --- a/docs/segment-key.md +++ /dev/null @@ -1,30 +0,0 @@ -# AACS 2.1 segment key tables (`AACS/SegmentKeyNNNNN.tbl`) - -One file per CPS unit (`SegmentKey00001.tbl`, ...). It is the on-disc key -store for the forensic variant segments mapped by `super::segment`. A device -does not read a segment key directly. It derives a 16-bit variant selector -from the Media Key Variant chain (see `super::variant`) and uses that -selector to index this table, which is how the device's position in the key -tree decides which variant it can decrypt (the traitor-tracing link). - -## Container format (confirmed against a retail AACS 2.1 disc) - -```text - header (8 bytes): u32 tag | u16 index_space | u16 record_size - record[index_space] (record_size bytes each) -``` - -On the reference disc: `index_space` = `0xffff` (the full 16-bit selector -space, 65536 records), `record_size` = `0x0218` = 536. Total -`8 + 65536 * 536 = 35,127,304` bytes, which matches the file exactly. Each -record begins with an 8-byte sub-header, then 528 bytes of encrypted key -material. - -## Not yet reversed - -The internal layout of a record's 528-byte payload, and how it maps onto the -segments of `super::segment`. One numeric coincidence worth noting for -whoever cracks it: the reference disc has 792 segments and `528 = 33 * 16`, -with `792 = 24 * 33`, so `33` appears on both sides. Until the mapping and -the key derivation are pinned, this module exposes only the confirmed -container: locate the record for a given 16-bit selector. diff --git a/docs/segment.md b/docs/segment.md deleted file mode 100644 index daf7a982..00000000 --- a/docs/segment.md +++ /dev/null @@ -1,39 +0,0 @@ -# AACS 2.1 FMTS forensic segment map (`src/aacs/segment.rs`) - -Background moved out of the module's rustdoc to keep the doc comments within -the comment-guard's prose caps. - -## Terminology - -The **index** carried by each segment record (1..32) is NOT the AACS 2.1 -*Media Key Variant* — that is the 65536-value device selector in the MKB -that decides *which set* of index keys a device receives, a layer this -module does not deal with. All the index keys belong to one variant, whose -number is unknown and irrelevant to the segment map. Decrypting a segment -with the ordinary CPS Unit Key yields garbage — broken HEVC reference -frames (empirically: `Could not find ref with POC …` on a plain unit-key -rip). - -## Measurements from a retail AACS 2.1 disc - -`index` is the 1..32 forensic index tag, NOT a sequential segment id: -measured on a retail 2.1 disc it cycles 1,2,…,32,1,2,… across records in -file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792 -records. Source-packet numbers are the 192-byte BDAV packet index: byte -offset = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned -units, spread across the entire 54 GB feature (one roughly every 67 MB). - -Inside a segment the 80 units interleave in two stride-2 halves: applying -the segment's index key decrypts ~40 of them to clean TS and garbles the -other ~40 (a second interleaved half, unidentified), which the demux then -drops — leaving one coherent stream. Confirmed by decoding a retail disc -with a full set of 32 index keys. - -## `index_to_key_idx` - -The `fmts_key_ranges` callback maps a segment's `index` to a position in the -key pool, e.g. `|i| i as usize` when the pool is laid out as -`[base, idx1, idx2, …]`. - -See also `docs/segment-key.md` for the on-disc key store these indices -select from. diff --git a/docs/session.md b/docs/session.md deleted file mode 100644 index 495f862f..00000000 --- a/docs/session.md +++ /dev/null @@ -1,106 +0,0 @@ -# session.rs — rationale and design notes - -Moved here from doc comments in `src/session.rs` to stay under the -comment-guard's per-item prose cap. See the pointers in that file for -which section applies to which item. - -## Module overview - -Lifecycle is intentionally SPLIT — `open` does transport mechanics only, -`identify` / `scan` are separate — so a consumer can fetch a poster off a -fast `identify` and update its UI before committing to a full `scan`. - -libfreemkv resolves no keys and reads no keydb: the consumer builds the -host credentials / key-source layer (from `freemkv_keysources`) and hands -them in via `KeySpec`; the session merely forwards them into -`ScanOptions` at scan time. No cert derivation happens here. - -## `resolve_keys_for` - -Steps, identical to what the consumers did inline: -1. Take the disc's public AACS inputs (`Disc::inputs`); a non-AACS disc has - none, so this is a no-op returning an empty trace and no fetch. -2. Sample up to `MIN_SAMPLE_UNITS` encrypted content units from the largest - title via `reader` (`read_encrypted_units`) so a candidate key is - validated against real ciphertext. Skipped (no wasted read) when the - factory yields no sources — resolution is then a guaranteed miss anyway. -3. Run the ordered sources first-valid-wins (`resolve_and_apply_traced`), - which banks the winning unit keys onto `disc`'s AACS state. -4. Build the read-time `KeyFetch` from the disc's inputs (its per-call - samples are swapped in by the closure) using the same source factory. - -The `reader` is whatever the disc lives behind — a live `Drive` or a -file-backed `SectorSource` from `scan_iso`; both implement `SectorSource`. - -## `DiscSession::into_drive` - -`Error::DeviceNotReady` is returned when the drive is no longer held — the -public `stage_drive_as_reader` moves it into the reader slot, and calling -this twice moves it out, so an empty slot is reachable through ordinary -use rather than being a caller error. A library must not panic from -public API, and a precondition that normal flow violates is a trap rather -than a contract. - -## `DiscSession::from_parts_for_test` - -Test-only constructor: build a session over an injected reader plus an -already-scanned disc without opening a live `Drive`. `DiscSession::open` -needs real hardware, so this is the only way to exercise the -`MuxInput::Session` mux arm (take_reader → resolve_inline_base_map → -DiscStream → with_key_map) and `resolve_keys`'s title-sampling branch -against a synthetic reader. - -The drive slot stays `None` (a `MuxInput::Session` mux never touches it — -it reads through the staged `reader`); `device` carries a sentinel path so -the driver's missing-reader error still has a name. - -`disc` is an `Option` so a test can construct a session that has not been -scanned (`None`) to exercise the `resolve_keys` "called before scan" guard. - -## `scan_iso` - -This is the file-backed counterpart to `DiscSession::scan`: it is the one -place that opens a `FileSectorSource`, reads its capacity, and runs -`Disc::scan_image`, so consumers (CLI, autorip) stop hand-rolling that -triple and stop constructing the low-level reader themselves. No SCSI, no -handshake, no key resolution — AACS resolution during the scan uses only -whatever `opts` already carries (mirroring how `Disc::scan_image` forwards -`ScanOptions`). - -The returned reader is a fresh handle positioned at the start of the -image; callers that need to sample ciphertext (key resolution) or feed a -mux can reuse it directly rather than re-opening the file. `Disc::scan_image` -reads only through the same reader, and all reads are LBA-addressed, so -the handle is fully reusable afterward. - -## `scan_dir` - -The extra step over `scan_iso` is the encryption verdict. `Disc::scan_with` -decides `encrypted` structurally, from the presence of an `/AACS` or -`/BDMV/AACS` directory (see `disc::aacs_dir_present`). For the common -case — a typical disc backup, which strips `AACS/` — that already gives -the right answer, and `DecryptKeys::None` is a pass-through. But a folder -copied verbatim from a decrypted disc keeps `AACS/`, and the tree shape -then claims encryption over content that is already in the clear: the rip -would fail asking for a key it does not need. - -So for a folder, tree shape is not the evidence — content is. Several -aligned units at the largest title's start are sampled and judged by -`aacs_unit_needs_decrypt`, the same authority the mux read path uses: - -* none need decryption → the folder is decrypted; `encrypted` is forced - false and the reason is logged. -* any unit does → the folder is a raw encrypted copy, which `dir://` does - not support; `Error::DirImageEncrypted`. - -This lives here and not in `Disc::scan_image`, which is shared with the -ISO and drive paths: an ISO that carries `AACS/` and clear content is a -different situation (it may be mid-decrypt, or `--raw` output), and the -verdict must not change underneath those callers. - -## `apply_folder_encryption_verdict` - -Shared by `scan_dir` and by the `dir://` PES input path in `mux::resolve`. -It lives in one place because the two disagreed: a folder that ripped -through `scan_dir` failed through `input()`, which is the exact failure -this probe was written to prevent, reachable by the other door. diff --git a/docs/sink.md b/docs/sink.md deleted file mode 100644 index 717878e3..00000000 --- a/docs/sink.md +++ /dev/null @@ -1,82 +0,0 @@ -# `src/io/sink` — output-sink trait split - -Two traits, one for each capability axis of an output destination: - -- [`SequentialSink`] — anything you can `Write` to in order. Sockets, - pipes, append-only stores, plain files. Containers that don't need - seek (M2TS, fMP4, HEVC elementary) target this. -- [`RandomAccessSink`] — everything `SequentialSink` plus a working - `Seek`. Local files, NFS files, anything with random-write - semantics. Containers that need backpatch (MKV cluster sizes, Cues - index, MP4 moov-at-end) target this. - -`RandomAccessSink: SequentialSink` — every random-access sink is -also a valid sequential sink. The muxer is generic over which it -requires (`MkvMux<S: RandomAccessSink>`, `M2tsMux<S: SequentialSink>`) -so an attempt to mux MKV to a network socket is a compile error. - -Buffering policy belongs to the concrete sink, not to a wrapper at -the call site. `LocalFileSink` wraps a `BufWriter<File>` with a -4 MiB buffer for the common local-disk case; `WritebackFile` -(separate module) wraps a `File` with the adaptive-chunk -`sync_file_range` machinery for the Linux+NFS case. - -## `SequentialSink::finish` - -`finish` drains any internal buffering and signals end-of-stream to -the underlying transport (close-write on a socket, flush + fsync on -a buffered file, etc.). The default impl flushes via [`Write::flush`] -— correct for an unbuffered destination — but every concrete sink in -this module overrides it to drain its own buffer and run its -transport-specific finalisation (socket `shutdown(Write)`, file -`fsync`). There is deliberately NO blanket `impl SequentialSink for -T`: a blanket impl would force the no-op-style default on every -concrete sink (a blanket impl cannot be overridden per-type without a -coherence conflict), so a `Box<dyn SequentialSink>` / `&mut dyn -SequentialSink` `finish()` call would silently skip the flush and -transport shutdown. With explicit per-type impls the vtable dispatches -`finish` to the real implementation, so flush + durable-finish -actually happen through a trait object. - -## `open_for_mkv` - -Picks the right `RandomAccessSink` impl for `dest` based on its -filesystem type. - -- Linux + NFS path → `WritebackFile` with its adaptive-chunk - sync_file_range machinery and (when supported) `fallocate` size - hint. -- everything else → `LocalFileSink` over `BufWriter<File>`. On - non-Linux there is no `WritebackFile` machinery to opt into, and - on local Linux the kernel's default writeback policy is already - fine. - -`size_hint`, when present, is forwarded to the per-OS preallocate -path (`fallocate(KEEP_SIZE)` on Linux, `F_PREALLOCATE` on macOS when -implemented, no-op elsewhere). - -Returns a boxed trait object so the call site (mux construction) -stays agnostic of which concrete sink got picked. - -Not yet wired into `mux::resolve` (follow-up commit). Kept -`pub(crate)` until then so an unfinished signature isn't frozen into -the public 1.0 API. - -## Test notes - -- `finish_through_trait_object_flushes_local_file` is the regression - test for the silent-no-op `finish()` bug: `finish()` through a `dyn - SequentialSink` trait object must dispatch to the concrete sink's - override (flush + fsync), not a no-op default. -- `FlushTracker` is a minimal `SequentialSink` that does NOT override - `finish`, so it exercises the trait's default impl, which must call - `Write::flush`. This pins the documented contract that the default - `finish` is "correct for an unbuffered destination" by flushing. - Mutation: changing the default `finish` body from `self.flush()` to - `Ok(())` would set `flushed=false` and fail. -- `open_for_mkv_without_size_hint_is_random_access` covers - `open_for_mkv` with `None` size hint: it must still produce a - working random-access sink (the `match size_hint { None => ... }` - arm). Round-trips a seek-back patch through it to prove both Write - and Seek dispatch. Mutation: if the `None` arm returned a - sequential-only sink the seek would not compile / would fail. diff --git a/docs/socket.md b/docs/socket.md deleted file mode 100644 index aff40354..00000000 --- a/docs/socket.md +++ /dev/null @@ -1,77 +0,0 @@ -# `src/io/sink/socket.rs` — socket sink design notes - -## `SocketSink` (TCP) - -Wraps a `TcpStream` in a 1 MiB `BufWriter`. Constructor tunes `SO_SNDBUF` -to a caller hint when provided. `finish()` flushes the buffer then -`shutdown(Write)`s the socket so the peer sees clean end-of-stream. - -`TCP_BUF_CAPACITY` (1 MiB) matches the typical kernel send-buffer -ceiling and keeps small-write amplification from containers (TS = -188-byte packets, fMP4 fragment headers = ~100 bytes) from translating -into syscall storms. - -The inner `TcpStream` is kept as a second clone (`shutdown_handle`) so -`finish()` can call `shutdown(Write)` after flushing the buffer — the -buffered writer doesn't expose the socket directly. - -`set_nodelay(true)` keeps small writes (TS packet trains, fMP4 moof -headers) from sitting in Nagle's algorithm; `BufWriter` already absorbs -syscall overhead. This is a latency hint, not a correctness requirement -— a rejecting platform must not fail connect. - -`shutdown(Write)` signals clean EOF to the peer. Errors from it are -non-fatal — the connection may have already been torn down by the peer -— but they are surfaced so callers can log. - -## `UdpSocketSink` (UDP) - -Wraps a connected `UdpSocket`. Each `write` call emits exactly one -datagram — the caller is responsible for packetizing to a reasonable -MTU (188 × 7 = 1316 bytes for MPEG-TS-over-UDP is the conventional -choice). `finish()` is a no-op; UDP has no end-of-stream marker, and -closing the socket happens on drop. - -`connect()` resolves the peer first so the local bind matches its -address family: binding `0.0.0.0:0` (IPv4) then connecting to an IPv6 -peer fails with `EAFNOSUPPORT`. Binding to the matching wildcard / any -port lets the kernel pick an ephemeral source port and the source IP -at first send. `connect` on a UDP socket doesn't open a connection — -it just fixes the peer address so subsequent `send` calls don't need -to repeat it, and so receive-side filtering rejects packets from other -sources. - -## Trait wiring - -Both types implement [`SequentialSink`] explicitly so their `finish()` -dispatches correctly through a `dyn SequentialSink` trait object (the -`SocketSink` override drains the buffer and `shutdown(Write)`s; the -`UdpSocketSink` override flushes only). Neither implements `Seek`, so -neither satisfies `RandomAccessSink` — using one with `MkvMux` is a -compile error, which is the design intent. - -## Platform `SO_SNDBUF` tuning - -std doesn't expose `SO_SNDBUF`; the code drops to libc on Linux + macOS. -Other targets silently ignore the hint — the socket still works, the -kernel just picks its own size. Non-Linux-non-macOS targets aren't in -Cargo.toml's libc dep list, so failing the connect there would be wrong -— it's a hint, not a guarantee, so there is intentionally no -"sndbuf applied" signal on those platforms. - -## Test notes - -- `finish_signals_eof_to_peer`: `SocketSink::finish` must call - `shutdown(Write)` so the peer's `read_to_end` observes EOF and - returns instead of blocking forever on a half-open socket. Mutation: - replacing the `shutdown(Write)` line with `Ok(())` makes the accept - thread hang and the join times out. -- `udp_write_is_one_datagram_per_call`: UDP `write` must emit one - datagram per call carrying exactly the bytes passed — no buffering, - no coalescing. Mutation: adding a `BufWriter` to `UdpSocketSink` - (explicitly forbidden) would merge writes into one datagram and the - second `recv` would time out. -- `udp_finish_is_noop_ok`: UDP `finish` is a documented no-op since - there is no EOF marker for UDP; it must not error and must not - affect prior datagrams. Mutation: if `finish` tried to `shutdown` - the UDP socket it could error or close it prematurely. diff --git a/docs/startcode.md b/docs/startcode.md deleted file mode 100644 index 39099d73..00000000 --- a/docs/startcode.md +++ /dev/null @@ -1,12 +0,0 @@ -# `BitReader` (mux/codec/startcode.rs) - -Minimal MSB-first bit reader over an RBSP, for the leading fields of a coded -slice header (H.264 `first_mb_in_slice` + `slice_type`; HEVC -`slice_segment_header`). - -It does NOT remove emulation-prevention bytes (`00 00 03`). Those can only -appear after two consecutive `0x00` bytes, which cannot occur within the -first Exp-Golomb codes of a slice header (a slice header never begins -`00 00`), so the leading fields this reader is used for decode correctly. A -caller reading deep enough into a header that `00 00 03` could appear must -de-emulate the RBSP first. diff --git a/docs/tables.md b/docs/tables.md deleted file mode 100644 index a9f5a8f5..00000000 --- a/docs/tables.md +++ /dev/null @@ -1,76 +0,0 @@ -# CSS spec table tests — rationale and mutation notes - -Background for the property tests in `src/css/tables.rs`'s `mod tests`. The -comments in that file point here for detail beyond the internal-comment cap. - -## tab1_is_a_permutation - -TAB1 is a bijection on 0..256. CSS uses it as an invertible output -permutation in `css_DecryptKey`'s chained-XOR rounds; if two inputs -collided, the key mangling would not be invertible. - -Mutation: duplicate any value (e.g. set `TAB1[1] = TAB1[0]`) -> the "maps -two inputs" assert fires. - -## tab1_known_spec_anchors - -TAB1's fixed structural anchors from the CSS spec table: `TAB1[0x00] == -0x33` and the inverse `TAB1[0x33] == 0x00`. These two entries are the -canonical first-row / inverse-lookup landmarks of the published CSS TAB1 -and pin the table's orientation. - -Grounding: CSS specification TAB1, row 0 col 0 = 0x33; index 0x33 (row 3 -col 3) = 0x00. - -Mutation: change the first literal `0x33` in TAB1 -> first assert fails. - -## tab2_is_a_permutation - -TAB2 is a permutation of 0..256 (it is the LFSR1 high-byte feedback -substitution). A non-bijective TAB2 would bias the LFSR1 keystream. - -Mutation: set `TAB2[8] = 0x00` (collides with `TAB2[0]`) -> assert fires. - -## tab3_matches_lfsr1_generating_formula - -TAB3 is the CSS LFSR1 low-word table: the 8-value feedback block -`BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]` repeated 64 times — -`TAB3[i] == BASE[i & 7]`. The high bits of the 9-bit index do not affect -the output (the LFSR1 step indexes with the full 9-bit low register but -only `& 7` matters). This pins all 512 entries to the published cipher's -table. - -Mutation: flip any single byte in the TAB3 literal -> the formula check -fails at that index. - -## tab4_bit_reversal - -TAB4 is the exact bit-reversal of each byte (CSS uses it to permute LFSR0 -bytes on seed and output). `TAB4[b]` reverses b's 8 bits MSB<->LSB. -Therefore it is also an involution: `TAB4[TAB4[b]] == b`. - -Grounding: `TAB4[0x01]=0x80`, `TAB4[0x80]=0x01`, `TAB4[0x00]=0x00`, -`TAB4[0xFF]=0xFF`. - -Mutation: set `TAB4[1] = 0x40` (not the reversal `0x80`) -> bit-reversal -check fails at index 1. - -## tab4_is_a_permutation - -TAB4 is a permutation (bit-reversal is bijective). Distinct from the -reversal test: a table that is "reversal except two swapped entries" would -still be a permutation, and a table that is "reversal except one -duplicated entry" would fail this but might pass a sampled reversal check -— the two tests pin different failure modes. - -Mutation: set `TAB4[2] = TAB4[1]` -> permutation assert fires. - -## tab5_permutation_anchors - -TAB5 is also a permutation (complement of a bijection is a bijection) and -its own self-consistency landmark: `TAB5[0x00] == 0xFF` (`TAB4[0]^0xFF`) -and `TAB5[0xFF] == 0x00` (`TAB4[0xFF]^0xFF`). Pins orientation independent -of the complement-loop test. - -Mutation: change the first TAB5 literal `0xff -> 0xfe` -> the landmark and -permutation checks both catch it. diff --git a/docs/testlog.md b/docs/testlog.md deleted file mode 100644 index 5c7503a6..00000000 --- a/docs/testlog.md +++ /dev/null @@ -1,63 +0,0 @@ -# src/testlog.rs — test-only `tracing` capture - -Test-only capture of `tracing` events, so the crate's logging contract is -ENFORCED rather than merely commented. - -## Why this exists - -The error contract is "Account / Log / Classify", and the round-2 audit -found the third leg unverifiable: three separate sites carry long comments -insisting they log **the error's OWN code, not a fixed one** — because -flattening a scratched sector (E6000) or an over-long allocation-descriptor -chain (E6016) into E6017 sends whoever triages them after authoring holes -and hides the population that actually exists. Nothing tested that. Putting -a literal back at `bluray.rs`'s or `hddvd.rs`'s warn sites broke no test, so -the guarantee was a convention one careless edit away from being false. Two -of those very sites were changed in round 1, and a third still carried a -hardcoded `code = 6017`. - -Likewise "absence of a log is itself a bug": a refusal that returns the -right error but says nothing produces the wrong population downstream (a -residual-underrunning drive is indistinguishable from a scratched disc). -That is only checkable by looking at what was emitted. - -## Why a hand-rolled subscriber and not `tracing-subscriber` - -Same posture as `crate::harness`: this crate has exactly one dev-dependency -on purpose. `tracing-subscriber` would pull a tree of them to do what forty -lines of `tracing::Subscriber` does here. The capture is installed with -`tracing::subscriber::with_default`, which is THREAD-LOCAL — so it composes -with `cargo test`'s parallel harness and two capturing tests cannot see -each other's events. - -Field values are stringified through `std::fmt::Debug`/`Display` because -that is all the visitor API offers without a typed schema; tests compare -against the string form of the expected constant, which is exactly the -comparison that catches a hardcoded code. - -## `capture()`: why one global subscriber, not scoped `with_default` - -`tracing`'s per-callsite INTEREST CACHE is GLOBAL, but `with_default` is -thread-local. Under `cargo test`'s parallel harness a rebuild of that cache -— triggered by ANY other thread registering any callsite for the first time -— re-evaluates the target callsite against the global dispatcher, which a -scoped subscriber is NOT part of, and can leave it cached "off" while a -capture is live, so the capture observes NOTHING. Serialising captures -against each other does not help: the poisoning thread is not itself -capturing. `parse_playlist_unreadable_clip_icb_yields_no_title` flaked ~1 -run in 15 on exactly this — an empty event list. - -The robust shape is a single subscriber installed globally for the whole -run, whose `register_callsite` returns `sometimes` (so no callsite is ever -hard-cached) and which routes each event to the emitting thread's own sink. -No scoped-dispatcher transitions, no cross-thread cache race, and concurrent -captures on different threads stay isolated by the thread-local sink. - -## `capture_records_target_level_and_fields` test rationale - -The capture must actually see events and their field VALUES — if it -silently recorded nothing, every logging assertion built on it would -pass vacuously, which is worse than having no harness at all. - -Mutation: an `enabled()` returning `false`, or an `event()` that drops -the visitor's fields, fails here. diff --git a/docs/truehd.md b/docs/truehd.md deleted file mode 100644 index 8f278db2..00000000 --- a/docs/truehd.md +++ /dev/null @@ -1,98 +0,0 @@ -# TrueHD codec parser — extended notes - -## `is_truehd_major_sync` — why 0xBA must be exact - -The 32-bit word that FOLLOWS the sync is laid out per stream type: TrueHD's -`format_info` carries [31..28] audio_sampling_frequency, the 5-bit 6-channel -and 13-bit 8-channel presentation channel-assignment masks; MLP's (0xBB) same -word carries quantization word lengths and the MLP group sample-rate fields -instead. So every site that decodes `format_info` with the TrueHD layout must -require 0xBA exactly — masking the low sync bit there read a quantization code -as the rate nibble and pulled channel masks out of unrelated bits, giving the -track header a wrong `SamplingFrequency` and `truehd_au_duration_ns` a wrong -per-AU increment (audio drifting against video for the whole track). -`None` from these helpers is the safe outcome: the caller falls back to its -container-derived rate/channel count. - -## `truehd_sample_rate_hz` — rate formula and rationale - -The MLP rate formula is `(ratebits & 8 ? 44100 : 48000) << (ratebits & 7)`; -rather than evaluate it blindly, `truehd_sample_rate_hz` is a strict -whitelist of the only six rates that occur on real BD/UHD TrueHD. Every -other code — the invalid `0xF`, the formula-only `0x3`/`0xB`, and all -reserved values — returns `None`. - -## `au_check` — corruption decision - -Decide whether an access unit is corrupt, updating `num_substreams` from a -valid major sync. Per the MLP/TrueHD access-unit decode rules: a major sync -with a bad header CRC, or any AU whose header parity fails, is undecodable. -Returns `false` (not corrupt) when the AU is too short to judge or no -major sync has established `num_substreams` yet — we never drop what we -cannot verify. Verified against real TrueHD streams (3600/3600 AUs). - -## `ac3_frame_at_head` — AC-3 size lookup - -Size (bytes) of the AC-3 frame at the buffer head. Distinguishes three cases -the caller must treat differently: -- `Unmappable`: the header's fscod/frmsizecod don't map to a real frame - size (reserved fscod==3, or frmsizecod >= 38). The caller must drain - and resync, NOT wait for more data — waiting would stall forever. -- `NeedMore`: a valid size, but the frame isn't fully buffered yet. -- `Frame(n)`: a complete `n`-byte AC-3 frame is buffered. - -Frame sizing reuses `ac3::ac3_frame_size` so the AC-3 size table has a -single source of truth shared with the AC-3 parser; a returned `0` there -(reserved fscod or out-of-range frmsizecod) is the unmappable case. - -## `ac3_boundary_corroborated` - -Secondary validation for an AC-3 frame of `frame_bytes` at the buffer head: -is its computed end a plausible boundary? Accept when the frame fills the -rest of the buffer, or the bytes that follow start another AC-3 sync -(0x0B77) or a plausible TrueHD access unit (non-zero 12-bit length within -the 32 KiB cap). If none holds, the leading 0x0B77 is more likely a TrueHD -AU header that happens to look like AC-3, so the AC-3 reading is rejected. - -## `mlp_major_sync_header_size` - -Major-sync header size in bytes: base 28, plus `2 + extensions*2` when the -extension flag (major-sync byte 25, bit 0) is set (`extensions` = byte 26 -high nibble). `ms` is the major-sync header, i.e. AU bytes `[4..]`. `None` -when the AU is too short to contain the full header. - -## `mlp_major_sync_crc_ok` - -Validate the MLP/TrueHD major-sync header checksum (a CRC-16 with polynomial -0x002D). The stored trailer is the last 2 header bytes; because MLP's -checksum is byte-reversed relative to a standard CRC, a standard CRC of the -header body XOR the little-endian word before the trailer must equal the -trailer read LITTLE-endian. (Comparing it big-endian was the bug this -function was fixed for; the body and the inline note are authoritative.) - -## `mlp_substr_header_size` — test rationale - -The substream count and the substream-directory size are the two numbers -that position `mlp_parity_ok`'s check window over the AU header. Every -other fixture in this module uses the degenerate shape — one substream, -no extraword — so neither function's real behaviour was ever exercised: -a constant answer agreed with all of them, and would then mis-window the -parity check on the multi-substream AUs that carry 7.1 and Atmos, judging -clean audio corrupt (or corrupt audio clean). - -## `ref_crc16_2d` test oracle - -Independent bitwise CRC-16 (poly 0x002D, init 0, MSB-first) — a separate -oracle from `crc16_mlp`, so a fixture built with it is not tautological -with the validator under test. Anchored to the catalogue check value -(0x4FF7 for "123456789") so the oracle itself is proven correct without -reference to the code under test. - -## `finalize_major_sync` test helper - -Turn a synthetic major-sync AU (sync bytes already set at offset 4, any -`format_info` set) into one that passes the decodability gate: 1 substream, -a clean substream directory, a valid major-sync CRC-16, and a valid header -parity nibble. Mirrors what a real encoder writes (verified against real -TrueHD streams). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync -header + 2 directory + slack), which every `make_truehd_unit(≥200)` is. diff --git a/docs/ts-demux.md b/docs/ts-demux.md deleted file mode 100644 index e29359e0..00000000 --- a/docs/ts-demux.md +++ /dev/null @@ -1,55 +0,0 @@ -# BD-TS demux (`src/mux/ts.rs`) — extended rationale - -Pointer target for `// See docs/ts-demux.md` comments in `ts.rs`. Each -section below expands a comment that was trimmed in the source for the -comment-guard's prose cap. - -## `MAX_PES_BUFFER_TOTAL` — aggregate PES-buffer ceiling - -`MAX_PES_BUFFER` bounds each PID's buffer independently and never sees the -total, while the tracked-PID count comes straight off the disc: -`TsDemuxer::new` makes one `PesAssembler` per SELECTED stream, and the -selection derives from the MPLS STN, whose per-category counts are `u8` -(up to 255 each across 8 categories) bounded only by the MPLS file's own -bytes. A crafted MPLS declaring 100 streams on 100 distinct PIDs, plus a -clip feeding each PID continuation packets (no PUSI) until just under the -per-PID cap, held 100 x 64 MiB = 6.4 GiB of PES buffers at once; 1000 -distinct PIDs — well inside the 8192-entry `pid_index` table — is 64 GiB. - -So the per-PID cap is derived from this total instead: `pes_cap` is -`MAX_PES_BUFFER_TOTAL / tracked_pids`, clamped to `MAX_PES_BUFFER`. A real -title selects a handful of streams and keeps the full 64 MiB each; only a -stream count far past anything an authored disc carries is squeezed, and -even then a complete HEVC/UHD access unit (1-3 MiB) still fits at ~170 -PIDs. Overflow is graceful in any case — the partial PES is dropped and -the assembler resyncs on the next PUSI, flagging a discontinuity. - -## `cc_is_gap` — continuity-counter gap test - -Canonical continuity-counter gap test (ISO/IEC 13818-1 §2.4.3.3). The -4-bit `continuity_counter` increments by one for each TS packet of a PID -that CARRIES PAYLOAD — a packet with adaptation field only does not -increment it, so such packets must be excluded by the caller rather than -diffed here. A packet MAY legally repeat the previous counter (the spec's -duplicate packet, whose payload is identical); that is not a loss. -Anything else means one or more packets for the PID were dropped. - -Single source of truth for BOTH users in this file: the PES assembler -(`process_packet`) and the PSI section reassembler (`collect_psi_section`). -`collect_psi_section` used to reimplement it as a strict `cc != expected`, -which rejected legal duplicates and legal AF-only packets — a -spec-conformant PMT continuation was then reported as desync and the -title's stream list came back empty. - -## `collect_psi_section` — PSI section reassembly across TS packets - -Reassemble a single PSI section (PAT / PMT) for `target_pid` with the -expected `table_id`, respecting TS-packet boundaries. The section pointed -at by `pointer_field` in the PUSI packet may be longer than the 184-byte -TS payload (PSI sections can reach 1021 bytes; a PMT with many ES entries -spans 2+ packets). Reading a flat slice of the input would walk straight -through the next packet's TP_extra_header + TS header as if it were table -content, yielding a wrong PID / garbage stream_type. This walks the PUSI -packet, applies `pointer_field` bounded to within that packet's payload, -then appends the payload of each subsequent continuation packet (same -PID, no PUSI) until `3 + section_length` bytes have been collected. diff --git a/docs/tsmux.md b/docs/tsmux.md deleted file mode 100644 index 67c4a99e..00000000 --- a/docs/tsmux.md +++ /dev/null @@ -1,76 +0,0 @@ -# tsmux — BD Transport Stream muxer (`src/mux/tsmux.rs`) - -PES frames in, 192-byte BD-TS (Blu-ray transport stream) packets out. Each -PES frame is wrapped in a PES header, split into TS packets, and prepended -with the 4-byte TP_extra_header. - -## `TsMuxer` framing rules - -PIDs in `0x1011..=0x101F` are treated as video (length-prefixed NALUs in, -Annex B out, with parameter-set prepend and RAI on keyframes); every other -PID is carried as `private_stream_1` (`0xBD`) audio/subtitle. All tracks -share one PTS origin seeded from the first video frame, so audio/video PTS -offsets are preserved. - -## `video_codec` field - -Decides BOTH how the ES is framed and how its `codec_private` parameter-set -record is parsed. One fact, one field: carrying NAL-ness separately from the -codec is what let a track be treated as NAL video while its avcC record was -handed to the hvcC parser. - -- HEVC / H.264 arrive length-prefixed (MKV/PES NALU convention) and need - Annex-B conversion; their parameter sets live in an hvcC / avcC record - respectively, and the two layouts are NOT interchangeable. -- MPEG-2 and VC-1 are already plain start-code ES; running - `length_prefixed_to_annex_b` over them mangles the frame into - empty/garbage output while `frame_count` still increments, so the mux - "succeeds" and silently produces a video-less file. - -Defaults to `Codec::Hevc` — the prior, only behaviour — so a caller that -never calls `TsMuxer::set_video_codec` is unaffected. Ignored for non-video -tracks. - -## `build_pes_header` — PTS on continuations - -ISO/IEC 13818-1 §2.4.3.7 puts the PTS in the header of the PES packet that -contains the FIRST byte of the access unit; repeating it on the -continuations makes each of them look like a new access unit at the same -timestamp, so a demuxer re-reading the stream splits one display set into -two blocks with identical timestamps. `pts_90k` is `None` for a -CONTINUATION PES packet (one carrying the rest of an access unit too large -for a single bounded-length private_stream_1 PES). - -## Test rationale - -### `non_nal_video_es_passes_through_unconverted` - -A non-NAL codec must pass the ES through byte-for-byte: MPEG-2 and VC-1 are -not NAL-based, so their ES already IS the wire format and -`length_prefixed_to_annex_b` would mangle it. The payload is deliberately -length-prefix SHAPED (a big-endian length followed by that many bytes) so a -wrongly-applied conversion rewrites the leading four bytes into a -`00 00 00 01` start code, making the two paths produce visibly different -bytes — a payload the converter happened to leave alone would let a mutant -pass. Mutation: delete the `set_video_codec` call, or make Mpeg2 report as -NAL, and the emitted ES gains a start code -> this fails. - -### `split_access_unit_carries_pts_only_on_the_first_pes` - -An oversized private_stream_1 access unit is split across several PES -packets, and every one of them used to carry the SAME PTS (PTS_DTS_flags = -0b10) even though only the first holds the start of the AU. On read-back a -demuxer treats each PUSI as a new access unit, so e.g. the second half of a -full-screen PGS display set arrived as an independent segment at an -identical timestamp — the display set was emitted as TWO blocks with the -same timestamp instead of one. - -### `annex_b_conversion_buffer_is_reused_across_frames` - -MEASURED: the Annex-B conversion buffer must be REUSED across video frames, -not allocated per frame. Both the allocation's address and its capacity are -unchanged after the second and third same-sized frames — if the conversion -allocated a fresh `Vec` per frame (the old -`Vec::with_capacity(data.len() + 1024)`), the buffer left on the muxer would -be empty with zero capacity, and each frame would pay an -allocate/first-touch/free cycle over the whole ~310 KB frame. diff --git a/docs/udf.md b/docs/udf.md deleted file mode 100644 index 48f5bde7..00000000 --- a/docs/udf.md +++ /dev/null @@ -1,440 +0,0 @@ -# UDF 2.50 Filesystem Parser - -## What is UDF? - -UDF (Universal Disc Format) is the filesystem standard for optical media. It is defined by ECMA-167 with extensions from the OSTA (Optical Storage Technology Association). BD-ROM discs use UDF revision 2.50, which introduces the **metadata partition** -- a critical feature that separates file metadata from file content on disc. - -## Why UDF 2.50 for Blu-ray? - -Older UDF revisions (1.02, 1.50) scatter ICBs (file metadata) and file data across the same partition. On a high-capacity Blu-ray disc (25-100 GB), this creates excessive seeking when the drive needs to read a directory listing or locate a file. UDF 2.50 solves this by placing all metadata into a contiguous region near the beginning of the disc. The drive reads metadata from one compact area and streams file data from another -- no interleaved seeks. - -BD-ROM Part 3 of the Blu-ray specification mandates UDF 2.50. - -## Metadata Partitions - -A UDF 2.50 BD-ROM has two logical partitions: - -- **Partition 0 (Type 1)** -- the physical partition. Contains actual file data (m2ts streams, playlist files, etc.). Mapped directly to disc sectors starting at the Partition Descriptor's `partitionStartingLocation`. - -- **Partition 1 (Type 2)** -- the metadata partition. Contains all ICBs (Inode-like structures), directory data, and the File Set Descriptor. The metadata partition is itself stored as a file within the physical partition. Its location is found by reading an Extended File Entry at LBA 0 of the physical partition. - -The key rule: **ICBs and directory data live in the metadata partition. File content lives in the physical partition.** When an ICB's allocation descriptor gives an LBA, the partition it refers to depends on what the LBA describes -- metadata-relative for directory entries, physical-partition-relative for file data extents. - -## Pointer Chain - -Reading a UDF 2.50 filesystem follows a fixed chain of pointers. Each step reads one or two 2048-byte sectors: - -``` -Sector 256: AVDP (Anchor Volume Descriptor Pointer, tag 2) - | - v -Sectors 32-63: VDS (Volume Descriptor Sequence) - |-- Partition Descriptor (tag 5) --> partition_start (physical sector) - |-- Logical Volume Descriptor (tag 6) --> partition maps, FSD location - | - v -Partition Maps in LVD (offset 440): - |-- Map 0: Type 1 (physical partition) - |-- Map 1: Type 2 (metadata partition, identified by "*UDF Metadata Partition") - | - v -Metadata file ICB at partition_start + 0 (Extended File Entry, tag 266) - |-- Allocation descriptor --> metadata content location - | - v -metadata_start = partition_start + allocation_position - | - v -FSD at metadata_start + 0 (File Set Descriptor, tag 256) - |-- Root Directory ICB: long_ad at offset 400 --> root_lba (metadata-relative) - | - v -Root Directory ICB at metadata_start + root_lba (Extended File Entry, tag 266) - |-- Allocation descriptor --> directory data location (metadata-relative) - | - v -Directory data: File Identifier Descriptors (tag 257) - |-- Each FID names a file/subdirectory and points to its ICB - |-- Recurse into subdirectories to build the full file tree -``` - -### AVDP (Sector 256) - -The Anchor Volume Descriptor Pointer is always at sector 256 (ECMA-167 section 10.2). It points to the Main Volume Descriptor Sequence. Tag identifier = 2. - -### VDS (Sectors 32+) - -The Volume Descriptor Sequence contains: - -- **Partition Descriptor (tag 5)**: byte offset 188 holds `partitionStartingLocation` -- the absolute sector where the physical partition begins. -- **Logical Volume Descriptor (tag 6)**: byte offset 268 holds the number of partition maps. The partition maps themselves start at offset 440. For BD-ROM, map 0 is Type 1 (physical) and map 1 is Type 2 (metadata). -- **Terminating Descriptor (tag 8)**: signals the end of the VDS. - -### Metadata File - -When two partition maps exist and the second is Type 2, the metadata partition content is located by reading the Extended File Entry at the first sector of the physical partition (partition_start + 0). This ICB's allocation descriptor gives the offset and length of the metadata content within the physical partition. - -### File Set Descriptor - -The FSD (tag 256) sits at metadata-relative LBA 0 (the first sector of the metadata content). It contains a long allocation descriptor at offset 400 pointing to the root directory ICB. The LBA in this long_ad is at bytes 404-407. - -### Directory Traversal - -Each directory is an ICB (Extended File Entry, tag 266, or File Entry, tag 261) whose allocation extent points to directory data. The directory data is a sequence of File Identifier Descriptors (FIDs, tag 257): - -| FID Field | Offset | Size | Description | -|-----------|--------|------|-------------| -| Tag | 0 | 2 | Always 257 | -| File characteristics | 18 | 1 | Bit 1 = directory, bit 3 = parent | -| L_FI (name length) | 19 | 1 | Length of filename | -| ICB (long_ad) | 20 | 16 | Points to the entry's ICB | -| L_IU | 36 | 2 | Implementation use length | -| Filename | 38 + L_IU | L_FI | UDF-encoded filename | - -FIDs are 4-byte aligned. The parser advances by `(38 + L_IU + L_FI + 3) & !3` bytes per entry. - -### ICB Layout - -Both File Entry (tag 261) and Extended File Entry (tag 266) share the same info_length field: - -| Field | Tag 261 Offset | Tag 266 Offset | -|-------|---------------|---------------| -| info_length (u64) | 56 | 56 | -| L_EA (u32) | 168 | 208 | -| L_AD (u32) | 172 | 212 | -| Allocation descriptors | 176 + L_EA | 216 + L_EA | - -Allocation descriptors use the Short Allocation Descriptor format: 4 bytes extent length (upper 2 bits = type), 4 bytes extent position (LBA). - -## How read_filesystem() Works - -The `read_filesystem()` function in `src/udf.rs` follows the pointer chain above: - -1. Reads sector 256, validates AVDP (tag 2). -2. Scans sectors 32-63 for the Partition Descriptor and Logical Volume Descriptor. -3. If two partition maps exist and the second is Type 2, reads the metadata file ICB at partition_start to find metadata_start. -4. Reads the FSD at metadata_start, extracts the root directory ICB LBA. -5. Calls `read_directory()` recursively (max depth `MAX_DIR_DEPTH` = 8) to build the full file tree. - -Each directory read involves two sector reads: one for the ICB, then one or more for the directory data. File sizes are read from info_length in each file's ICB. - -`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start). - -## Buffered Sector Reads - -USB optical drives have ~500ms round-trip latency per SCSI command. Since `read_filesystem()` and `read_file()` issue one SCSI READ per sector, a full disc scan can require hundreds of commands -- taking 10+ minutes on USB. - -`Disc::scan()` wraps the drive in a `BufferedSectorReader` before reading. On a single-sector read, the buffer prefetches a batch of sectors (sized from the kernel's `max_hw_sectors_kb` for the device) and caches them. Subsequent reads to nearby LBAs return from cache with zero SCSI overhead. After parsing the UDF directory structure, the entire metadata partition is pre-read into the cache, so all ICB lookups during title scanning and encryption resolution are instant. - -`prefetch_ranges` pre-reads multiple sector ranges into the permanent cache, -in batch-sized chunks, stored per-sector in a `HashMap`. This is used to -bulk-load all small files (AACS, MPLS, CLPI, META) before scanning. The -permanent cache holds one ~2 KB `Vec` per sector in the `HashMap`, so the -total sector count bounds RAM; a crafted UDF (a bogus metadata-file size in -`metadata_sector_ranges`) could otherwise drive that count to billions. The -cumulative prefetched sectors are capped at `MAX_PREFETCH_SECTORS` (~1 GiB -of cache); once exceeded, prefetching stops seeding the cache. The -sliding-window read path still serves any LBA on demand, so this only -forgoes the bulk speed-up — it never loses data. - -The buffer is transparent -- `read_filesystem()`, `read_file()`, and all downstream code still call `read_sectors(lba, 1, buf)` as before. The batching happens inside the `SectorSource` implementation. - -### UDF Filename Encoding - -UDF filenames use a compression ID as the first byte: -- `8` = 8-bit characters (ASCII) -- `16` = 16-bit big-endian Unicode (UTF-16BE) - -The parser handles both encodings. All path lookups are case-insensitive. - -## BD-ROM Directory Structure - -A typical Blu-ray disc has this directory layout: - -``` -/ -+-- BDMV/ -| +-- index.bdmv Disc index (title list, first play) -| +-- MovieObject.bdmv Movie objects (navigation commands) -| +-- PLAYLIST/ -| | +-- 00000.mpls Main movie playlist -| | +-- 00001.mpls Director's commentary -| | +-- ... -| +-- CLIPINF/ -| | +-- 00001.clpi Clip info for 00001.m2ts -| | +-- 00002.clpi -| | +-- ... -| +-- STREAM/ -| | +-- 00001.m2ts Transport stream (video/audio/subtitle data) -| | +-- 00002.m2ts -| | +-- ... -| +-- BACKUP/ Duplicate of index, MovieObject, playlists, clip info -| -+-- AACS/ AACS encryption data (encrypted discs only) -| +-- Unit_Key_RO.inf Unit key file (encrypted) -| +-- MKB_RW.inf Media Key Block -| +-- Content000.cer Content certificate -| +-- DUPLICATE/ Backup copies -| -+-- CERTIFICATE/ BD+ certificate data (some discs) -``` - -The parser reads from `BDMV/PLAYLIST/` and `BDMV/CLIPINF/` to discover titles and their sector layouts. The `BDMV/STREAM/` directory contains the actual transport streams but is not parsed by the UDF layer -- stream data is read by LBA directly using extents computed from CLPI EP maps. - -## metadata_sector_ranges() skip policy - -`metadata_sector_ranges()` skips directories named `STREAM` (case-insensitive) -and individual files larger than 50 MB. Nothing else is filtered by name — -`BACKUP`/`DUPLICATE` are traversed, and `MKB_RO.inf` is excluded only because -it exceeds the 50 MB cap. - -## metadata_file_location() and read_directory() internals - -`metadata_file_location` returns `None` when the map does not fit wholly -inside the descriptor, or when its partition type identifier is not -"*UDF Metadata Partition" — a Virtual (UDF 2.50 2.2.8) or Sparable (2.2.9) -partition map is also ECMA-167 3/10.7.3 Type 2 and records unrelated fields -at that offset, so its bytes must never be read as a location. - -`read_directory` walks each directory's ICB (Extended File Entry) to find -directory data containing File Identifier Descriptors (FIDs); each FID names -a file/subdir and points to its ICB. Directories deeper than `MAX_DIR_DEPTH` -are recorded as entries but not descended into. `budget` tracks total FID -entries consumed across the whole tree walk and the walk aborts with -`Error::DiscRead` once it exceeds `MAX_TOTAL_DIR_ENTRIES`. `visited` is the -set of metadata-relative ICB LBAs already opened as directories; a repeated -LBA is a cycle and is skipped. The function's wide argument list (reader, -partition/meta offsets, depth, the global entry budget, and the -cycle-detection visited-set) is inherent to the walk, not a refactor smell. - -## extents_abs_at(), file_extents(), and file_extents_addressing() - -`extents_abs_at` resolves multi-extent / Long-AD / continuation ICBs to -absolute disc extents. Unrecorded (ECMA-167 4/14.14.1.1 type-1) extents are -included: their space is allocated to the file and occupies its byte space, -so dropping them would slide every later extent's bytes down by the hole's -length in a sequential extraction. They are returned FLAGGED -(`AbsExtent::recorded == false`), because keeping the extent while losing -the flag is the same bug the other way round: the caller would then read -sectors the file never wrote and ship whatever the media holds there. -`read_file_limited` emits zeros for such an extent; every consumer of this -list has to be able to do the same. It is `pub(crate)` because it returns -`AbsExtent`, itself internal. - -`file_extents` refuses a file that contains an unrecorded extent because a -`(lba, sector_count)` pair cannot say "this range is a hole": reading it -splices undefined sectors into the stream as content, and dropping it -slides every later extent's byte space. Until an extent can carry the flag -end-to-end, refusing is the only truthful answer at this signature — and a -title that is not offered is not a title that was silently mis-ripped. - -`file_extents_addressing` is the byte-space-only counterpart: it includes -unrecorded extents UNFLAGGED, in a shape identical to `file_extents`'s safe -return, because omitting one would misaddress the rest of the file and -nothing on this path treats the returned sectors as stream bytes. It stays -`pub(crate)` rather than public: an external consumer reaching for the more -general-sounding name would silently obtain a read plan over a hole, and a -doc comment alone is not a guard against that — scoping makes the misuse a -compile error. - -## read_icb_extent(): first RECORDED extent, not extents.first() - -`read_icb_extents` retains ECMA-167 4/14.14.1.1 type-1 (allocated, not -recorded) descriptors, because dropping one would slide every later extent's -data down by the hole's length. But a type-1 extent's `lba` is where SPACE is -allocated, not where bytes live, and `read_icb_extent`'s one caller — -`file_start_lba` — hands its result out as "the absolute starting LBA of a -file's first data extent". `ifo.rs` then uses that as the base for every VTS -VOB extent (`file_start_lba(IFO) + vtstt_vobs + cell.first_sector`), so a -file whose FIRST descriptor is type-1 would put the entire video title set -at the wrong place on disc, with no error anywhere. `read_icb_extent` -therefore returns the first extent with `recorded == true`, not -`extents.first()`. - -## read_inline_data() and the AACS .inf regression - -Tiny files such as the AACS `*.inf` key files are routinely embedded -directly in the ICB (ICB Tag flags low 3 bits == 3) rather than via -out-of-line extents. `read_icb_extents` finds no real extents for them (it -would misparse the embedded payload as allocation descriptors), so -`read_file` must read the inline payload via `read_inline_data` instead. A -0.31.0 regression added a per-extent `MAX_FILE_BYTES` cap that turned the -misparsed-embedded case into a hard error, which surfaced as autorip "could -not read this disc's key files" on discs whose AACS `.inf` files are -ICB-embedded — the keyserver was then never called. - -## MAX_FILE_BYTES and the AACS MKB - -`read_file()` caps a single unbounded read at `MAX_FILE_BYTES` (64 MiB) to -bound the allocation a crafted ICB `info_length`/extent length can force. -The one legitimately huge file on disc, the AACS `MKB_RO.inf`, is allocated -to a fixed ~128 MiB and zero-padded, so it is NOT read through this unbounded -path: `read_aacs_inputs_from_reader` instead reads a bounded prefix via -`read_file_prefix` and trims to the real record length, so it never trips -the cap (and never reads 100+ MiB of padding). A 0.31.0 regression read the -MKB through the unbounded path instead, so the cap rejected it, -`read_aacs_inputs` failed, and autorip reported "could not read this disc's -key files" without ever contacting the keyserver. - -## merge_ranges() half-open semantics - -`merge_ranges` takes HALF-OPEN `(start, count)` ranges, so `start + count` -is the EXCLUSIVE end and two ranges touch exactly when the next `start` -equals the previous end. A range that starts one sector LATER than that has -a genuine, untouched sector between them, and merging across it claims -coverage of a sector neither input ever described. That matters because the -merged output is a boundary, not a hint: `disc::merged_extents` feeds -`Disc::encrypted_content_ranges`, which decides which sectors the -decrypting source treats as content. A sector in a real gap belongs to no -title extent — it is nav/UDF/padding — and must stay outside the content -gate, not be folded into it. - -## build_dir_icb_tagged() offsets - -`build_dir_icb_tagged` builds a directory ICB with an explicit descriptor -tag, extended attribute length, RAW (unmasked) allocation-descriptor length -field and allocation position. ECMA-167 4/14.17 Extended File Entry (tag -266): L_EA at byte 208, L_AD at 212, allocation descriptors start at -216 + L_EA. ECMA-167 4/14.9 File Entry (tag 261): L_EA at 168, L_AD at 172, -allocation descriptors start at 176 + L_EA. The extended-attribute area is -filled with a recognisable non-zero pattern (0xA5) so that reading the -allocation descriptor from the wrong offset cannot silently produce a -usable value. - -## Test fixture builders - -`build_efe_long` builds a tag-266 ICB whose allocation descriptors are LONG -ADs (16 bytes: len(4) | lba(4) | part_ref(2) | impl_use(6)); the trailing -`part_ref`/`impl_use` bytes are left zero, which is what trips the old -8-byte-stride parser into reading a bogus zero-length terminator. - -`build_entry_ads` builds a tag-261/266 entry with short ADs and every -disc-controlled field of the descriptor area exposed: `l_ea` (extended -attribute length — descriptors begin at the entry type's own base, 176 for -261 or 216 for 266, plus this; the attribute area is filled with 0xA5 so a -descriptor read from the wrong offset cannot silently produce a usable -value), `l_ad` (the DECLARED descriptor-area length, independent of how many -descriptors are actually written), and `extra` (descriptors written -immediately after the declared area, i.e. bytes the entry does not claim are -descriptors at all). - -## VDS anchor shape-vs-content fallback tests - -An anchor whose recorded VDS extent passes every SHAPE check (length >= 16 -sectors, non-zero location, no address wrap) but holds no sequence must -still mount, by sweeping the customary location. Shape is a property of the -FIELD, not of what is there: a mastering tool that wrote the reserve -location, a stale anchor on a rewritten volume, or deliberate corruption all -produce this. Selecting the fallback on shape alone would leave the branch a -damaged disc actually takes with no recovery path at all — the sibling -Metadata File Location chain in the same function already treats its -recorded value as a CANDIDATE and falls back on outcome; this is that -principle applied one level up. - -The recorded extent and the customary fallback are two SWEEPS, not two -locations: an anchor may point at the customary LBA and still declare a -window narrower than the fallback's. De-duplicating them on start LBA alone -then throws the wider sweep away and the sequence is never reached, even -though it is exactly where the fallback would have looked. The regression -test's fixture records `(LBA 32, 16 sectors)` — the minimum ECMA-167 -3/10.2.1 permits, so it passes every shape check — while the Partition and -Logical Volume Descriptors sit at 50/51, inside the customary 32-sector -window but past the declared 16. No Terminating Descriptor lies in 32..48, -or both sweeps would stop at the same sector and the wider one would buy -nothing. - -`conformant_meta_vol`'s extent length and position are given distinct byte -patterns, and distinct patterns from one another, so a descriptor byte -sourced from the wrong offset — or the two fields transposed — changes the -reported metadata partition rather than landing on a value that happens to -work. The length is an exact multiple of the 2048-byte logical sector, as a -real metadata partition is: its only observable is a sector COUNT, which -rounds up, so an off-by-one byte in the low half is visible only when the -true value sits exactly on the boundary. - -## u32::MAX overflow guards in BufferedSectorReader - -`prefetch` advances the read LBA with `start_lba + offset`. A UDF whose -metadata descriptor declares a partition near the top of the LBA space -(ECMA-167 §14.1: `extent_location` is an unconstrained Uint32) drove that -add past `u32::MAX` — an 'attempt to add with overflow' panic in debug -inside the public `Disc::scan`, and in release a wrap to a low LBA that -filled the sliding cache with a completely different region while -`cache_start` still claimed the high one. - -`prefetch_ranges` walks each disc-derived `(start_lba, sector_count)` range -with `start + offset + i`. `collect_file_ranges` permits any LBA up to -`u32::MAX` (ECMA-167 §14.14.1 allocation descriptors are unconstrained -Uint32s) and nothing bounds `start + count`, so a range within one batch of -the top of the space overflowed: debug panic inside `Disc::scan`, or in -release a wrap that seeded the PERMANENT cache with this file's bytes keyed -at LBA 0 — every later single-sector read of those low LBAs (the AVDP/VDS -re-reads) then returned the wrong sector. - -The sliding-cache hit test computed `cache_start + cache_sectors`. Once -`cache_start` is a disc-controlled LBA near `u32::MAX` (set by a batch read) -that add overflowed: debug panic inside `SectorSource::read_sectors`, -release wrap to a small value that silently disabled the cache. - -## file_extents() refusal and continuation-cycle test rationale - -`file_extents` builds the extent list a Blu-ray / HD-DVD TITLE is actually -ripped through (`disc/bluray.rs`, `disc/hddvd.rs`, `mux/resolve.rs`). Its -`(lba, sector_count)` tuple cannot say "this range is a hole", and both ways -of pretending otherwise are wrong: folding an unrecorded extent in makes the -mux read undefined sectors and splice whatever the media holds there into -the stream as content, while dropping it slides every later extent's byte -space (that list IS a byte-space map, `aacs::segment::clip_byte_to_lba`). So -at this signature the only truthful answer is refusal. - -`icb_extents_exhausted_continuation_budget_is_an_error_not_a_short_list` -covers hostile input: a type-3 continuation descriptor whose continuation -block points back at itself (a cycle). The `MAX_AD_BLOCKS` bound must make -this TERMINATE rather than loop forever, and terminating by exhausting the -budget is not the same as reaching the end of the chain. It used to fall -through to `Ok(extents)` with the list silently cut short at whatever the -256th hop had collected; `disc/extract.rs` then zero-pads the missing tail -out to the entry's declared size, sets `complete = true` with -`bytes_unreadable = 0`, and renames off `.partial` — a mostly-zero file -delivered as a verified complete extraction. Exhausting the budget means "I -do not know the rest of this file", and the only honest answer is an error. - -## Embedded-data and zero-length-hole test fixtures - -AD type 3 is EMBEDDED data, not a descriptor list. RED BEFORE GREEN: with -the old `_ => 8` fallback, `read_icb_extents` returned -`Ok([IcbExtent { lba: 999, len: 2048 }])` — an extent decoded out of the -file's own CONTENT bytes. A rip would then read sector 999, which holds -something else entirely, and emit it as this title's stream at rc=0. That is -the failure the refusal exists to prevent, so the -`icb_extents_embedded_data_is_refused_not_decoded_as_descriptors` fixture -deliberately makes the content decode as a PLAUSIBLE extent rather than as -garbage: garbage would have been caught by the terminator check anyway, and -would prove nothing. - -`file_extents` refuses a file whose extent list contains an unrecorded hole, -because reading one splices undefined sectors into the rip and dropping one -slides every later extent's byte space. Neither is true of a hole with -LENGTH ZERO: it displaces nothing, every later extent sits at the same -offset with or without it, and the callers' own `sectors > 0 && lba > 0` -filter already discards it. Refusing on `!recorded` alone therefore dropped -whole titles off discs that ripped correctly before — the reverse of the -defect the refusal exists for, and just as silent, since the title simply -vanishes. `file_extents_accepts_a_zero_length_unrecorded_extent` covers this. - -## build_efe_ext() extended allocation descriptor layout - -`build_efe_ext` builds an Extended File Entry (tag 266) ICB whose allocation -descriptors are EXTENDED ADs (ECMA-167 §14.14.3, 20 bytes each): -`ExtentLength(4) | RecordedLength(4) | InformationLength(4) | ExtentLocation -lb_addr { logicalBlockNumber(4) | partitionRef(2) } | impl_use(2)`. The -30-bit length + 2-bit type live in ExtentLength (offset +0); the logical -block number lives in ExtentLocation at offset +12. It sets ICB Tag flags -(abs offset 34) low bits to 2 = Extended AD so the parser must select the -20-byte stride and read the LBA from off+12, not off+4. - -## References - -- ECMA-167: Volume and File Structure of Write-Once and Rewritable Media -- UDF 2.50 (OSTA): Universal Disk Format Specification -- BD-ROM Part 3: Blu-ray Disc Read-Only Format, File System Specifications diff --git a/docs/unlock-bridge.md b/docs/unlock-bridge.md deleted file mode 100644 index d4800778..00000000 --- a/docs/unlock-bridge.md +++ /dev/null @@ -1,32 +0,0 @@ -# unlock_bridge - -Bridges libfreemkv's drive layer to the `freemkv-unlock` crate: one generic -SCSI-transport adapter, identity/host-cert mapping, and the dispatch that -assembles the unlocker list and runs each one's `unlock()` until one claims -the drive. libfreemkv names no individual unlocker — it only calls this bridge. - -## `Dispatch` - -Result of a dispatch: `(matched_name, result)`. `matched_name` is the -unlocker that handled it (or `""` if none did) — lets the caller record WHICH -unlocker ran (e.g. `LD` vs `Renesas`), distinct from the id-only identity -lookup `unlocker_name`. Iterating stops at the first unlocker whose -`unlock()` returns anything other than `Ok(None)` — i.e. an actual unlock -(`Ok(Some)`) OR a real failure such as a dead bus (`Err`), which the caller -must surface rather than skip. - -## `run_features` / `run_bus` - -Both build their own fixed `Vec<Box<dyn fu::Unlocker>>` — `run_features` the -firmware unlockers (freemkv, LD, Renesas), `run_bus` the disc-keyed ones -(AACS, DVD) — and hand it to the shared `run()` loop. There is no -`all_unlockers()` registry; adding or removing an unlocker means editing -both this file's two lists and `unlocker_names()` below. - -## `unlocker_names` - -The unlocker names, in dispatch order, for the user-facing unlocker matrix. -A hardcoded `vec!["freemkv", "LD", "Renesas", "AACS", "DVD"]` — kept in sync -with `run_features`/`run_bus` by hand, not registry-driven. The per-unlocker -"did it run this rip" outcome is computed by the caller, which has the disc + -drive runtime state this crate cannot see. diff --git a/docs/variant.md b/docs/variant.md deleted file mode 100644 index 6ddd6e81..00000000 --- a/docs/variant.md +++ /dev/null @@ -1,369 +0,0 @@ -# AACS Variant chain internals — relocated rationale - -Supplementary notes for `src/aacs/variant.rs` comments that exceed the -comment-guard's cap. Each section is pointed to by a short -`// See docs/variant.md — <topic>` comment at the relevant call site. - -## Module status - -The record layout is pinned against real variant MKBs: `C` is the -per-slot block of the `0x0c` cvalue table (indexed by the matched -subset-difference — NOT the `0x2d` head), `VARIANTS[uv]` is the `0x2d` -VARIANTS table (leading `body-16` bytes, Nonce at the `0x2d` tail), and -`VKD` is `0x2f`. Two inputs still block an end-to-end run against the -`0x86` Verify-Media-Key record: the real per-licensee KCD (see -`KEY_CORRECTION_DATA` — not coded, per-manufacturer), and a covering 2.1 -Processing Key. Either one missing yields a wrong `Km` that the final -verify gate rejects, so a bad key is never emitted — only an error. (A -covering key would also confirm the last layout picks: the 16-bit `Kvn` -width and Nonce head-vs-tail.) - -Two condition bits on `Kmp[15]` route off the default KCD path (Soft -Correction and Online Challenge); the chain does not model those modes -and treats such a slot as non-covering. - -## Verify gate - -On the classical path `walk_processing_key` gates each match on the -VERIFY_MAGIC relation, which authenticates the Processing Key. On a -variant MKB that magic does NOT hold (the walk yields a Precursor, not -the Media Key), so the authoritative gate is at the END of the chain: -the derived `Km` is verified against the MKB's Verify-Media-Key record -before it is ever returned. - -## MKB records this chain selects - -`REC_MEDIA_KEY_VARIANT_DATA` (`0x0c`), `REC_VARIANT_DATA_AND_NONCE` -(`0x2d`), `REC_VKD_TABLE` (`0x2f`), the subset-difference/cvalue and -verify records — canonical set in `super::mkb`. - -## `KEY_CORRECTION_DATA` - -**KCD is PER-LICENSEE** (per player manufacturer) — there is no single -universal value. libfreemkv compiles in no AACS key material (keydb.cfg -is the single source of truth), so this stays all-zero: the chain's -SHAPE still runs, but on a real variant disc the derivation yields a -wrong Media Key that the final Verify-Media-Key gate rejects. The -variant chain therefore cannot complete on a real disc today — a -key-acquisition gap, not a code gap. If a real per-licensee KCD is ever -available it must come from keydb.cfg, never a compiled constant. - -## `is_variant_mkb` - -The real AACS 2.1 Variant markers — confirmed against a live variant -MKB — are `0x2d` (Encrypted Media Key Variant Data / C) and `0x2f` -(Variant Key Data table, 65,535×16). Both are absent from non-variant -1.0/2.0 MKBs (which instead carry `0x05` host-revocation-signature and -no `0x0c`/`0x2d`/`0x2f`). The earlier `0x82`/`0x83` guess was -speculative and never appeared in any real MKB. - -## `variant_data_record` - -Body of the `0x2d` record: the `VARIANTS` table followed by the -trailing 16-byte `Kvn` Nonce. Measured `46_100*2 + 16 = 92_216` on one -v70 disc and `92_220` on another — in both, the leading `body.len() - -16` bytes are the big-endian `u16` `VARIANTS` table (one per -subset-difference) and the last 16 bytes are the Nonce, with NO leading -header. This does NOT hold the C used for `Kmp` — that is the per-slot -block in `0x0c` (`REC_MEDIA_KEY_VARIANT_DATA`). Both `variant_nonce` -and `variants_for_uv` read this body. - -## `variant_nonce` - -The Nonce-at-tail placement is consistent across both reference MKBs -(the leading `body-16` bytes form the `VARIANTS` table exactly), but -head-vs-tail is only truly pinned by running the full chain against the -`0x86` verify with a covering key. Until then a wrong nonce can only -fail that final gate, never emit a bad key. - -## `variant_key_data` - -Confirmed against a live variant MKB: exactly 65,535 × 16 = 1,048,560 -bytes, indexed by the resolved `VKDidx`. This is disc-public data (it is -why the VKD alone buys nothing without the Media Key chain above it). - -## Subset-difference walk sharing - -`calc_v_mask`/`calc_pk_from_dk` (and the AES-G3 seed step) are shared -with the classical walk in `super::derive`, keeping the variant SD tree -byte-identical. `aesg3` itself is imported separately in the test -module. - -## `walk_processing_key` - -Walks an MKB and returns the first `(Kp, uv, cvalue)` that -`device_keys` covers, or `None` if no DK walks any uv. - -This is the AACS-2.1 **variant** walk; the classical walk lives in -`super::derive::derive_media_key_and_pk_from_dk`. The two are kept -separate on purpose and select MKB records in DELIBERATELY different -order: - -- cvalues: this variant walk tries record `0x0c`-then-`0x07`-then-`0x05` - (`0x0c` is the real-disc per-uv cvalue source; `0x07`/`0x05` are - fixture fallbacks); the classical walk tries `0x05`-then-`0x07`. On a - variant MKB the small `0x07` Explicit-Subset-Difference record carries - the cvalue the Precursor chain consumes, whereas a classical UHD MKB - keeps its 1:1 cvalue table in the large `0x05` record (see the note - on `super::derive::probe::mkb_cvalues`). They must NOT be unified to - one order — each is correct for its own MKB shape. -- finders: this walk operates on parsed `MkbRecord`s (needed because the - variant chain also reads `0x2d`/`0x2f`); the classical walk operates on - raw MKB bytes. Same framing, different input type. - -Consequence: do NOT route the classical DK path through this function — -on a classical MKB the `0x07`-first selection picks the wrong (or -missing) cvalue and the magic check fails, so it returns `None`. - -## `variants_for_uv` - -Looks up the per-slot `VARIANTS` value for the matched subset-difference -slot, keyed by the same index that selected the cvalue -(`ProcessingKeyMatch::cvalue_index`). - -LAYOUT (fixed against a real 2.1 variant MKB — a v70 `MKB_RO.inf`): the -`0x2d` Encrypted-Media-Key-Variant-Data body is exactly `46_100*2 + 16 = -92_216` bytes, i.e. one **big-endian u16 `VARIANTS` entry per -subset-difference slot** (1:1 with the `0x0c` variant cvalues and the -`0x04` subset-differences), with the 16-byte per-disc Nonce packed at -the **tail** (see `variant_nonce`). So the VARIANTS table is the -leading `sd_count*2` bytes and this reads its `sd_slot_index`-th entry. - -The record/field *sizing* is confirmed; the one bit still to pin against -a covering key is Nonce-head-vs-tail (both fit the size) — a wrong pick -can only yield a wrong `Km`, which the final Verify-Media-Key gate -rejects (never a silent bad key). - -## `derive_media_key_variant` - -The one deterministic `Kp → Km` derivation for a variant MKB. A leaked -2.1 Processing Key arrives without its subset-difference slot, so this -tries `pk` against every slot and returns the Km for the slot whose -full chain passes the MKB's Verify-Media-Key record — exactly the shape -of the classical bare-PK `super::derive::derive_media_key_from_pk`, -gated by the chain's own verify so an unverified key is never returned. - -VID-free by design: the Media Key is MKB-scoped. Derive the per-disc -VUK from the returned Km with `super::derive::derive_vuk`. Deriving a -Processing Key from device keys (DK → PK) is a separate concern — walk -it first via `walk_processing_key`, then call this. - -Errors: `NotVariantMkb` (caller should use the classical path), -`MkbIncomplete` (a required record is missing), or -`ProcessingKeyUnavailable` (no slot verified — `pk` does not cover this -MKB, or its slot needs the soft-correction / online path, surfaced as -`SoftCorrectionRequired` / `OnlineChallengeRequired`). - -## `media_key_variant_from_kp` - -Runs the variant chain from a caller-supplied Processing Key and -EXPLICIT per-slot inputs — the harness entry that tries a captured `Kp` -against known slot material, bypassing both the device-key walk and the -on-MKB `VARIANTS[uv]` lookup. The caller supplies the `0x0c` C block, -the slot's subset-difference number `uv`, and its `VARIANTS[uv]`; the -MKB supplies the Nonce, the VKD table, and the Verify-Media-Key value. - -Returns `(Km, Kvu)`. The terminal Verify-Media-Key gate is identical to -`derive_media_key_variant`, so a wrong `c_block` / `uv` / `variants_uv` -returns `MediaKeyVariantError::MediaKeyVerifyFailed` rather than a bogus -key. The soft-correction / online-challenge bits on `Kmp[15]` are -classified the same way, so a slot needing an out-of-band correction -path is distinguishable from a non-matching input. - -(Note the KCD caveat on `KEY_CORRECTION_DATA`: without the real -per-licensee KCD this fails the verify gate on a real disc — a -key-acquisition gap.) - -## Test fixture: `synthetic_variant_setup` - -Builds a synthetic variant MKB plus a DK that walks the single -subset-difference slot it carries. `kmp15` is the value of the low byte -of `Kmp[15]` that the chain will land on — pick `0x02` to exercise the -SoftCorrection bit, `0x04` to exercise OnlineChallenge, `0x00` -otherwise. - -The fixture pins: -- MKB subset-difference: `u_mask_shift=3, uv=2`. With these masks the - discriminator bit (u_mask=1, v_mask=0) is bit 2. -- one DK at `node=4, uv=2, u_mask_shift=3`. node 4 has bit 2 set - (differs from uv=2 on bit 2 → disagrees on v_mask) while agreeing - with uv on bits 3+ (the u_mask=1 region). dk.uv == MKB.uv and - dk.u_mask_shift == MKB.u_mask_shift make `dev_key_v_mask == v_mask`, - so `calc_pk_from_dk` loops zero times — Kp = aesg3(dk, 1). -- one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a Kmp - whose byte-15 is exactly `kmp15`. -- record 0x2d (Encrypted Media Key Variant Data): a 32-byte body - carrying C in the head 16 bytes and a 16-byte Nonce in the tail. -- record 0x2f (Variant Key Data): one 16-byte entry. - -Returns `(records, dk, planted_kp, planted_kmp)`. - -## Test fixture: `plant_variant_mkb` - -Builds a variant MKB by inverting the 2.1 chain for a CHOSEN `(Kp, -Km)`. One subset-difference slot (`uv = 2`, `u_mask_shift = 3`, slot -index 0). The VKD the chain must land on is planted at index **1** of -the `0x2f` table, behind a decoy at index 0, so `VARIANTS[0]` is -load-bearing: it is chosen as `Kvn XOR 1`, and any other value selects -the decoy (wrong `Km`, rejected by the verify gate) or indexes past the -table. - -## Test: `variant_chain_derives_the_planted_media_key_for_a_covering_kp` - -THE happy path: a Processing Key covering slot 0 of a complete variant -MKB must derive the planted Media Key. This is the assertion the whole -2.1 chain hangs from — `derive_media_key_variant` is what `resolve` -calls for a 2.1 disc, and its output becomes the VUK, the title keys -and every decrypted byte. The assertion lands on the FINAL derived -Media Key, so no intermediate step (VARIANTS lookup, VKD index, Kpnew, -the unwrap) can be replaced by a constant and still pass. - -## Test: `mkb_find_mk_dv_returns_the_verify_records_actual_bytes` - -`mkb_find_mk_dv` supplies the block the terminal verify gate compares -against. A body answering a FIXED block would make the gate compare -every derived Media Key against a record no disc carries: on a real -disc every correct key is rejected (2.1 discs stop resolving entirely), -and any key that happened to open the fixed block would be accepted -wholesale. - -## Test: `variants_for_uv_reads_the_planted_table_entry_that_selects_the_vkd` - -`variants_for_uv` reads `VARIANTS[slot]` — the value XORed with `Kvn` to -index the VKD table. A body answering a constant picks the WRONG VKD -entry for every disc, so the derived Media Key fails the verify gate -and every 2.1 variant disc reports `ProcessingKeyUnavailable` with a -perfectly good Processing Key in hand. Asserted two ways: the exact -planted table entry, and — the load-bearing one — that this entry is -what carries the chain to the planted Media Key. - -## Test: `variant_uv_slots_drops_zero_uv_and_out_of_range_shift_slots` - -`variant_uv_slots` enumerates the slots the chain will try a Processing -Key against, and it must drop the two shapes that are unusable — and -dangerous — rather than pass them on: - -- `uv == 0`: no subset-difference. It would be XORed into `Kmp` and `Km` - as a no-op and the slot would be tried against every VKD entry. -- `u_mask_shift >= 32`: out of range for a `u32` shift. `0x20..=0x3F` - have the `0xC0` revoked-marker bits CLEAR, so they pass the table - terminator and reach the `wrapping_shl` in the walk, where shift 32 - silently means shift 0 (`u_mask = 0xFFFF_FFFF`) and matches a slot the - device does not cover. - -Both bytes are disc-supplied. Every existing fixture uses one in-range -non-zero slot, so neither rejection was executed. - -## Test: `media_key_variant_from_kp_derives_the_planted_media_key_and_volume_unique_key` - -THE happy path for the EXPLICIT-INPUT entry point. `media_key_variant_from_kp` -is the harness twin of `derive_media_key_variant`: same chain, but the -caller supplies the `0x0c` C block, the slot's `uv` and its -`VARIANTS[uv]` instead of having them looked up on the MKB. - -Before this test, the ONLY test that entered this function asserted the -`Kmp[15]` soft-correction bit — it returned before the Kpnew, Kvn, VKD, -Km and Kvu steps ever ran. Every arithmetic step past that early return -was executed by nothing, so a body that computed `Kpnew = Kmp | KCD`, -indexed the VKD table at `Kvn + VARIANTS` or dropped the `uv` XOR out of -`Km` produced exactly the same observable behaviour. - -The assertion lands on the returned `(Km, Kvu)` — the two values that -become every title key and every decrypted byte on a 2.1 disc. - -## Test: `media_key_variant_from_kp_refuses_every_single_wrong_explicit_input` - -The terminal gate on the explicit-input entry. `media_key_variant_from_kp` -takes three caller-supplied values (`c_block`, `uv`, `variants_uv`); -each one wrong must yield `MediaKeyVerifyFailed`, never a key. Without -this, a harness feeding a mis-transcribed slot would be handed 16 bytes -that look exactly like a Media Key. - -## Test: `plant_walk_variant_mkb` - -Builds a two-slot variant MKB keyed by a DEVICE key at slot **1**. -Positions follow the same reasoning as the classical -`derive::position_recovery_tests::plant_mkb`: `uv = 0x0400` -(`u_mask_shift = 12`) with a device node of `0x0C00` satisfies the `[C]` -§3.2.4 gate — equal under `u_mask = 0xFFFF_F000`, different under -`v_mask = 0xFFFF_F800`. The device key's own `uv` equals the slot's, so -`dev_key_v_mask == v_mask` and `calc_pk_from_dk` descends zero levels: -`Kp = AES-G3(dk, 1)`, written out explicitly rather than taken from the -walk's own output. - -Slot 0 is a decoy at `uv = 0x0800`, which the SAME device node fails -the `v_mask` half of the gate against (`0x0C00 & 0xFFFF_F000 == 0x0800 -& 0xFFFF_F000`), so the walk must skip it and land on slot 1. - -## Test: `walk_processing_key_returns_the_covering_slots_key_cvalue_and_index` - -`walk_processing_key` must return the Processing Key, `uv`, cvalue AND -slot index of the covering slot — slot **1**, not slot 0. This is the -DK → Kp step the entire 2.1 chain starts from. Every prior test of it -asserted either `None` or merely `is_some()`, and all used a one-slot -MKB where every stride multiplies by zero. A body that read the -subset-difference at the wrong stride, sliced the wrong cvalue block, -or returned the slot-0 cvalue for a slot-1 match would have passed all -of them — and produced a Processing Key that opens nothing. - -The expected `Kp` is written as the explicit `AES-G3(dk, 1)` -zero-descent relation from `[C]` §3.2.4, not taken from the walk's own -output. - -## Test: `walk_processing_key_refuses_a_device_key_that_fails_the_subset_difference_gate` - -The gate the walk applies is `[C]` §3.2.4's subset-difference test, and -a device key that fails it must get NO match. Pinned across all four -coordinates the gate reads — node, uv, u_mask_shift and the key bytes — -because a body that dropped any half of the gate would hand back a -Processing Key derived at the wrong tree position. - -## Test: `a_trailing_partial_subset_difference_chunk_is_not_parsed_as_a_slot` - -A `0x04` subset-difference record whose byte count is not a multiple of -5 must have its trailing partial chunk REFUSED, not parsed as a slot. - -The walk sizes the table with `take_while(|c| c.len() == 5 && ...)`. -Drop the length half of that conjunction and the partial chunk is -counted, and the very next line reads `p_uv[0..4]` off a slice with -fewer than four bytes left — an index-out-of-bounds PANIC on a -disc-supplied record length. This is untrusted input: a truncated or -crafted MKB reaches this with no other guard in between. - -## Test: `a_cvalue_table_shorter_than_the_matching_slot_is_not_sliced_past` - -A `0x0c` cvalue table SHORTER than the matching slot index must make -the walk skip the slot, not slice past the end of the record. -`cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]` is an unchecked slice; the -only thing in front of it is `if uvs_idx >= cvalues.len() / 16`. The two -counts come from DIFFERENT disc-supplied records (`0x04` and `0x0c`), -so nothing but this guard keeps them in agreement — a real MKB with a -short cvalue table panics the rip thread without it. - -## Test: `walk_processing_key_needs_either_the_verify_magic_or_variant_records` - -The classical-magic escape hatch. On a NON-variant MKB the walk must -return a match only when `AES-D(Kmp, mk_dv)` opens with the `[C]` -§3.2.5.1.4 verify magic; on a variant MKB that relation does not hold -(the walk yields a Precursor) and the presence of `0x2d`/`0x2f` is what -lets the match through to the chain's own terminal gate. - -Both halves of `classical_ok || variant_present` are pinned here: strip -the variant records from a fixture whose magic does NOT hold and the -walk must go quiet. Otherwise a body that dropped the guard entirely -would return an unauthenticated Processing Key on every classical MKB. - -## Test: `walk_processing_key_authenticates_a_classical_match_through_the_verify_magic` - -The OTHER half of `classical_ok || variant_present`: a non-variant MKB -whose cvalue really does open the Verify-Media-Key magic must yield a -match, and the `[C]` §3.2.4 relation that produces the candidate — -AES-D(Kp, cvalue) with `uv` XORed into the LOW FOUR BYTES — must be -computed exactly. - -This is the only path on which that XOR is observable. On a variant MKB -`variant_present` short-circuits the magic test, so the whole -`km_candidate` computation is dead weight there: a body that ORed `uv` -in, or XORed it at the wrong offset, changes nothing any variant -fixture can see. On a CLASSICAL MKB it is the entire authentication of -the Processing Key. diff --git a/docs/vc1.md b/docs/vc1.md deleted file mode 100644 index 8d2943a9..00000000 --- a/docs/vc1.md +++ /dev/null @@ -1,75 +0,0 @@ -# VC-1 elementary stream parser — internal notes - -Relocated prose from `src/mux/codec/vc1.rs` internal (non-pub) items, kept -here in full since it exceeds the 3-line internal-comment cap. - -## `parse_vc1_interlace` - -Read the advanced-profile sequence header's `INTERLACE` flag (SMPTE 421M -§6.1.1): bit 41 after the start code — after PROFILE(2) LEVEL(3) -COLORDIFF_FORMAT(2) FRMRTQ(3) BITRTQ(5) POSTPROCFLAG(1) MAX_CODED_WIDTH(12) -MAX_CODED_HEIGHT(12) PULLDOWN(1). `None` for simple/main profile or a header -too short / over-escaped to reach the bit. De-escapes emulation-prevention -bytes first (as `parse_vc1_resolution` does) so the bit offset is exact. - -## `vc1_progressive_ptype` - -Decode the advanced-profile **progressive** picture PTYPE VLC (SMPTE 421M -§7.1.1.4, Table): `0`=P, `10`=B, `110`=I, `1110`=BI (intra → I), `1111`= -Skipped (predicted, no residual → P). Only valid when the sequence is -progressive — for interlaced an FCM code (and, for field pictures, a combined -FPTYPE) precedes/replaces PTYPE, so the caller declines those. - -## `vc1_frame_coding_type` - -Measure the coding type of an advanced-profile frame from its picture header. -`frame_rbsp` starts immediately after the frame start code (`00 00 01 0D`). -Decodes PTYPE only for a PROGRESSIVE sequence (where PTYPE is the first -picture-layer field); declines (`None`) for interlaced/simple-main/unknown -rather than guess at the wrong bit offset. - -## `handle_header` - -Handle a seq_header or entry_point start-code unit (Annex B raw bytes). - -Decision is against the currently-ACTIVE body `cur`, not the codecPrivate -copy `first`: -- First of its type → seeds codecPrivate; stripped (decoder gets it from - the BITMAPINFOHEADER extra data at init). -- Equal to the active set `cur` → redundant; stripped. -- Different from `cur` (a change in EITHER direction, including reverting - to the codecPrivate/first value) → prepended into `prefix` in Annex B - form and `cur` updated. - -Returns `true` when the unit was emitted into `prefix`. - -## `parse_vc1_resolution` - -Parse width and height from a VC-1 advanced profile sequence header. -The sequence header starts with 00 00 01 0F. After the start code: -byte 0 bits 7-6: profile (3 = advanced). Advanced profile seq-header -layout (SMPTE 421M, from sh[4]): PROFILE(2)+LEVEL(3)+COLORDIFF_FORMAT(2)+ -FRMRTQ_POSTPROC(3)+BITRTQ_POSTPROC(5)+POSTPROCFLAG(1)=16 bits, then -MAX_CODED_WIDTH(12)+HEIGHT(12) = 40 bits total = 5 bytes. - -## `make_ap_seq_header` (test helper) - -Build an advanced-profile VC-1 sequence header encoding the given -width/height. Layout from sh[4]: PROFILE(2)=3, LEVEL(3), COLORDIFF(2), -FRMRTQ(3), BITRTQ(5), POSTPROCFLAG(1) = 16 bits, then -MAX_CODED_WIDTH(12) = width/2 - 1, MAX_CODED_HEIGHT(12) = height/2 - 1. - -## `vc1_emits_entry_point_revert_to_first_value` (test) - -Regression: entry_point is redefined from A (== codecPrivate) to B, then -switched BACK to A. A streaming decoder applied codecPrivate at init and -is now on B; the revert to A must be emitted IN-BAND even though A == -codecPrivate, or the A-segment decodes against the wrong entry point. - -## `vc1_keyframe_prefix_order_seq_unchanged_entry_redefined` (test) - -Regression: keyframe where seq_header is UNCHANGED (stripped by scan) but -entry_point is REDEFINED (changed). Before the fix, the old code appended -entry_point during the scan, then reassert() appended seq_header AFTER it, -producing [entry_point, seq_header] — entry_point before seq_header, -violating SMPTE 421M. After the fix, assembly is always seq-then-entry. diff --git a/docs/videomap-mod.md b/docs/videomap-mod.md deleted file mode 100644 index 1a37542b..00000000 --- a/docs/videomap-mod.md +++ /dev/null @@ -1,27 +0,0 @@ -# `src/mux/videomap.rs` — design notes - -Each per-picture record's coding truth ([`PictureInfo`], off `frame.coding`) -and source provenance ([`SourcePos`], off `frame.source`) come from what the -highway already stamps on the frame — this module never re-parses the -elementary stream itself. - -`VideoMap` is a STANDALONE PRIMITIVE, deliberately decoupled from any one -sink: - -- The `fvi://` sink (`crate::mux::fvi_sink`) owns a `VideoMap`, appends each - video `PesFrame`, and serializes it. -- The same `VideoMap` can later be populated as a side-channel during ANY - mux (e.g. `iso → mkv` while ALSO emitting a `.fvi` sidecar), and reused for - seek-indexing, recovery loss-mapping, and diagnostics. - -A different output format would be a DIFFERENT sink reusing this same model, -not a pluggable encoder inside `videomap.rs`. - -## Regression: `fvi_colour_follows_hdr_and_measured_cicp` - -An HDR10 BT.2020 title's real transfer is PQ (16); a measured CICP triplet -is authoritative and copied through verbatim. Before the fix, the FVI -`Colour` reported transfer=14 (the SDR default for BT.2020) while the MKV -container reported 16 — two sinks of one title disagreeing on the colour -code points. - diff --git a/docs/videomap.md b/docs/videomap.md deleted file mode 100644 index 7ee626b2..00000000 --- a/docs/videomap.md +++ /dev/null @@ -1,10 +0,0 @@ -# `mux::videomap` - -Reusable, pure-data per-picture video index (the FVI logical model) -consumed by [`fvi_sink`](../src/mux/fvi_sink.rs). Serialization-independent. - -`#[allow(dead_code)]`: the `VideoMap` accumulator is a standalone primitive -staged for the side-channel (mux-while-indexing) reuse described in its -module doc; the `fvi://` sink today builds `PictureRecord`s directly, so -the accumulator is covered only by its own unit tests until that wiring -lands. diff --git a/docs/vm.md b/docs/vm.md deleted file mode 100644 index ee0a7fe7..00000000 --- a/docs/vm.md +++ /dev/null @@ -1,5 +0,0 @@ -# HDMV navigation VM - -The VM "mimics a real player": correct power-on register state is what lets -a densely-branched dispatcher converge on the feature `PlayPlayList` rather -than loop indefinitely. diff --git a/docs/vocab.md b/docs/vocab.md deleted file mode 100644 index 503dfa5b..00000000 --- a/docs/vocab.md +++ /dev/null @@ -1,145 +0,0 @@ -# `src/labels/vocab.rs` — shared label vocabulary - -Labels come from BD-J authoring tool files (bluray_project.bin, -playlists.xml, menu_base.prop, .class string pools, etc.) — NOT from BD -spec fields. This module is the central, regression-tested source of -truth for: - -- Codec brand name aliases (`MLP` → `TrueHD`). -- English / multi-word language name → ISO 639-2 code. -- English text → `LabelPurpose` (Commentary / Descriptive / etc.). -- English text → `LabelQualifier` (SDH / Forced / Descriptive Service). - -Not for BD spec STN codec IDs; those decode in `mpls.rs` separately. - -## Rules of engagement - -1. Only map values we are 100% certain about — published codec names, - well-known ISO 639-2 mappings, vendor-documented purpose keywords. -2. Unknown codes / unrecognized phrases pass through raw or return - `None`. We never guess. -3. Matching is case-insensitive and word-boundary-aware where relevant - (so "Commenter" doesn't match "commentary"). Anchoring on whole - tokens is the responsibility of this module — callers pass raw - text, we handle it. - -## `lang()` — free-form language name → ISO 639-2 + variant - -Handles both bare English names ("English", "Spanish") and the -multi-word vendor variants seen in the corpus ("Brazilian Portuguese", -"Castilian Spanish", "Canadian French"). Match is case-insensitive. - -Compound phrases are scanned BEFORE bare names, so "Brazilian -Portuguese" returns `LangInfo { code: "por", variant: "Brazilian" }` -rather than being consumed by the bare "Portuguese" entry. Within -`COMPOUND_LANGS` the scan is positional (first `contains` hit wins), so -that table MUST be maintained longest-first — a longer phrase must -precede any shorter phrase it contains (e.g. "latin american spanish" -before "latin spanish"). - -Bare-name matches return `variant: ""`. Returns `None` for unrecognized -input — callers decide whether to fall back to MPLS spec codes, pass -through raw, or drop the stream. Never guesses. - -Why the variant: returning only the ISO code would silently drop -regional dialect info — "Brazilian Portuguese 5.1" would become -`language="por", variant=""` and the UI would display plain -"Portuguese" even though the disc explicitly labeled the stream -Brazilian. Returning the variant lets callers populate -`StreamLabel::variant` with the dialect. - -## `iso639_1_to_iso639_2()` — the whole ISO 639-1 set - -Covers the WHOLE of ISO 639-1, unlike `menu_lang`, whose table only -spans the languages that show up in Blu-ray menu-graphic filenames. -Callers that convert a spec field — a DVD IFO attribute block, say — -need the whole set: narrowing it to the menu vocabulary would fold -every other language onto one value and make a disc's tracks -indistinguishable from each other. - -`ISO_639_1_TO_2` pairs every ISO 639-1 two-letter code with its ISO -639-2/**T** (terminological) code — the variant the rest of this crate -uses (`deu` not `ger`, `fra` not `fre`, `zho` not `chi`, plus `ces`, -`nld`, `ell`, `ron`, `slk`, `isl`, `eus`, `hrv`) — so `lang` and -`menu_lang` both normalize to it and the three tables cannot disagree -(`iso639_1_agrees_with_menu_lang` pins that). For the 165 codes where -639-2/B and /T are identical this distinction does not arise; it only -matters for the 20-odd languages with a distinct bibliographic code. - -`ISO_639_1_DEPRECATED` covers the three two-letter codes ISO 639-1 has -since withdrawn, mapped to their replacements. DVD-Video froze its -language list on the 1988 edition, so discs authored to the spec carry -these spellings and no other table sees them: `iw` Hebrew (now `he`), -`in` Indonesian (now `id`), `ji` Yiddish (now `yi`). - -## `purpose()` / `qualifier()` — English text → enum - -`purpose()` recognized keywords (case-insensitive; single-word keywords -are word-boundary matched, multi-word phrases are substring matched): -- "commentary", "director's commentary" → `Commentary` -- "descriptive", "description", "audio description", "described" → `Descriptive` -- "score", "music only" → `Score` -- "ime" (alternate music for closing themes etc.) → `Ime` -- anything else → `Normal` - -Word-boundary matching means "Commentary track" matches but "Commenter -Pro audio" does not. Multi-word phrases like "audio description" and -"music only" are matched as plain substrings. - -`qualifier()` recognized keywords (case-insensitive, word-boundary -matched): -- "sdh", "captions" → `Sdh` -- "forced", "forced narrative" → `Forced` -- "rnib", "descriptive service" → `DescriptiveService` -- anything else → `None` - -SDH (Subtitles for the Deaf and Hard of hearing) wins over Forced when -both keywords are present, because an SDH track is its own stream -regardless of whether the player flags it as "forced". - -## `has_word()` — word-boundary primitive - -The load-bearing primitive for `lang` / `purpose` / `qualifier`: -bare-token matchers MUST use it, otherwise we match "english" inside -"englishman" and "sdh" inside "lambdash". The existing parsers used -`.contains()` and got lucky on the corpus; vocab guarantees the -boundary. - -Char-aware, not byte-level: an accented/CJK char adjacent to the match -is alphanumeric and so NOT a boundary, preventing false positives like -"sdh" inside "cafésch". Needles are ASCII, so byte offsets align with -char bounds. - -## Test rationale (mutation-testing notes) - -- `purpose_descriptive_service_substring_without_word_boundary`: the - multi-word-compound fast path in `purpose()` ORs two independent - phrase checks ("audio description" / "descriptive service"). Each - phrase, when it appears as a *word*-bounded match, is independently - caught by the `has_word` fallback further down — so the OR only - matters when a phrase appears as a *substring inside a larger word* - (no boundary), which `.contains()` still catches but `has_word()` - would reject. Mutation: replace `||` with `&&` at the compound check - → since "audio description" is absent in the test input, the AND - fails, the fast path doesn't fire, and the fallback - `has_word("descriptive")` also fails (no word boundary before - "descriptive" in "nondescriptive"), so `purpose()` wrongly returns - `Normal` instead of `Descriptive`. - -- `menu_lang_covers_every_table_entry`: `menu_lang()` maps every - authoring-filename token in its table (ISO-639-2/B and /T spellings, - plus ISO-639-1) to the canonical /T code used by the rest of the - pipeline. Exhaustive per-arm check: deleting any single match arm - makes that arm's tokens return `None` instead of the documented code. - -- `iso639_1_table_is_complete_and_well_formed`: structural invariants - of `ISO_639_1_TO_2` — it must hold the complete ISO 639-1 set (184 - codes), every key a distinct pair of lowercase letters and every - value three lowercase letters. A typo'd or duplicated row fails here - rather than silently mislabelling a track. - -- `iso639_1_agrees_with_menu_lang`: the two tables must not disagree. - Every two-letter token `menu_lang` accepts has to yield the same ISO - 639-2/T code through `iso639_1_to_iso639_2`, so a DVD-sourced - language and a Blu-ray menu-label language for the same tongue never - produce different `Language` elements. diff --git a/docs/writeback-file-linux.md b/docs/writeback-file-linux.md deleted file mode 100644 index acc36bdc..00000000 --- a/docs/writeback-file-linux.md +++ /dev/null @@ -1,57 +0,0 @@ -# `io::writeback_file::linux` — durable-sync rationale - -## `durable_sync` - -Runs `fsync` on the file with a 60 s deadline. On timeout, halt, or a lost -worker we log and return `Err` — matching macOS. POSIX gives `fsync` exactly -one way to say "the data is on stable storage" and that is a zero return; a -call that never reached the device has not earned it, so `Ok(())` from here -means the flush completed and nothing else. - -The kernel will still flush on close, so the data is usually durable -anyway — but that is a probability, not a barrier, and a caller that needs -crash-consistency has to be able to tell the difference. - -### fd-reuse safety - -The `fsync` runs on a bounded worker thread that may be leaked on timeout. -To avoid the leaked worker's syscall hitting a recycled fd number after the -original `File` is closed, we `try_clone` an owned `File` and move it into -the closure. The clone keeps the underlying file description alive for as -long as the worker thread lives. On `try_clone` failure (rare) we fall back -to the raw fd integer — no worse than the previous behaviour. - -## `durable_sync_worker_uses_owned_clone_with_distinct_fd` (test) - -Regression for the fd-reuse / use-after-close fix in `durable_sync`. - -Verifies the structural invariant: `try_clone` succeeds for a normal local -tempfile, and the cloned `File` has a distinct fd number from the original. -This pins the property that a leaked fsync worker thread captures an owned -`File` (and thus keeps the file description alive) rather than a bare fd -integer that can be reused after the original `File` closes. - -The actual fd-reuse race is non-deterministic and not cleanly testable -without coordinating a simultaneous close + re-open on another thread. A -structural test is the accepted substitute. - -## `bounded_failure_to_result` - -Maps a [`crate::io::bounded::BoundedError`] from the bounded `fsync` onto -the `io::Error` `durable_sync` returns. - -Every arm means the same thing: no sync observably ran. All three used to -return `Ok(())`, so `WritebackFile::sync_all` reported success for a -durability barrier that never happened. POSIX gives `fsync` one way to say -"the data is on stable storage" — a zero return — and a call that never -reached the device has not earned it. - -This mirrors the macOS `F_FULLFSYNC` mapping exactly. The two were found -carrying the identical defect, and a platform disagreeing with its sibling -about whether a failed sync is an error is the "works on my platform" class -this crate has been bitten by before — most recently an over-length SCSI -CDB that macOS rejected and the other two silently truncated. - -No message text (this crate ships no user-facing English): the kind, and -`EIO` for the worker-lost case, are the signal; `tracing` carries the -detail. diff --git a/docs/writeback-file-macos.md b/docs/writeback-file-macos.md deleted file mode 100644 index 3759fbe2..00000000 --- a/docs/writeback-file-macos.md +++ /dev/null @@ -1,74 +0,0 @@ -# WritebackFile macOS platform impl - -## `preallocate` - -`fcntl(F_PREALLOCATE)` — macOS's fallocate-equiv. First attempt requests -`F_ALLOCATECONTIG | F_ALLOCATEALL` (prefer a contiguous run but accept -scattered extents to satisfy the full length), falling back to -`F_ALLOCATEALL` alone on failure. `F_PREALLOCATE` never advances EOF -regardless of the flags — only `ftruncate`/writes grow the file — so the -reported file size is unchanged; `F_ALLOCATEALL` governs the contiguity -fallback, not size. - -## `durable_sync` - -`fcntl(F_FULLFSYNC)` wrapped in [`crate::io::bounded::bounded_syscall`] -with a 60 s deadline. F_FULLFSYNC is HFS+/APFS's true-fsync (flushes the -disk's own write cache) — what `fsync` should have been on macOS. Falls -back to plain `fsync` if F_FULLFSYNC returns ENOTSUP. - -### fd-reuse safety - -The F_FULLFSYNC / fsync runs on a bounded worker thread that may be -leaked on timeout. To avoid the leaked worker's syscall hitting a -recycled fd number after the original `File` is closed, we `try_clone` -an owned `File` and move it into the closure. The clone keeps the -underlying file description alive for as long as the worker thread -lives. On `try_clone` failure (rare) we fall back to the raw fd integer -— no worse than the previous behaviour. - -## `bounded_failure_to_result` - -Maps a [`crate::io::bounded::BoundedError`] from the bounded -`F_FULLFSYNC` onto the `io::Error` `durable_sync` returns. - -Every arm here means the same thing: **no sync observably ran**. All -three previously returned `Ok(())`, so `WritebackFile::sync_all` -reported success for a durability barrier that never happened — a total -failure exiting 0, with only a log line to distinguish it. POSIX gives -`fsync` exactly one way to say "the data is on stable storage" and that -is a zero return; a call that never reached the device has not earned -it. - -The errors carry no message text (this crate ships no user-facing -English): the kind, and `EIO` for the worker-lost case, are the whole -signal, and the `tracing` lines above/below carry the operator detail. - -## Test notes - -### `durable_sync_worker_uses_owned_clone_with_distinct_fd` - -Regression for the fd-reuse / use-after-close fix in `durable_sync`. -Verifies the structural invariant: `try_clone` succeeds for a normal -local tempfile, and the cloned `File` has a distinct fd number from the -original. This pins the property that a leaked F_FULLFSYNC/fsync worker -thread captures an owned `File` (keeping the file description alive) -rather than a bare fd integer that can be reused after the original -`File` closes. The actual fd-reuse race is non-deterministic; a -structural test is the accepted substitute. - -### `every_bounded_failure_is_reported_as_an_error` - -Every `BoundedError` arm of the bounded `F_FULLFSYNC` means no sync -observably ran. All three returned `Ok(())`, so `sync_all` reported a -durability barrier that never happened — the caller could not tell a -completed flush from a skipped one by any means except reading a log. -Asserted on the concrete `ErrorKind` / `errno` each arm must produce, so -a future arm that quietly reverts to `Ok(())` fails here. - -### `bounded_failures_are_never_mapped_to_ok` - -The failure path must be reachable through the public surface: a -`WritebackFile::sync_all` that hits any of these arms must surface an -`Err`, not a silent `Ok`. Pinned at the mapping boundary because the -timeout itself is not deterministically inducible in a unit test. diff --git a/docs/writeback-file-windows.md b/docs/writeback-file-windows.md deleted file mode 100644 index 9a5df108..00000000 --- a/docs/writeback-file-windows.md +++ /dev/null @@ -1,16 +0,0 @@ -# WritebackFile Windows platform impl - -## `preallocate` - -Debug-logged no-op. Windows has no `fallocate`-equivalent that keeps the -reported size, so extent reservation is not wired up. - -## `durable_sync` - -Delegates to the std `File::sync_all`, which on Windows maps to -`FlushFileBuffers`. Unlike the Linux/macOS impls this is NOT wrapped in -the bounded-syscall primitive (that would need an `unsafe impl Send` for -`RawHandle`, which cannot be validated without a Windows test env), so a -wedged UNC/SMB share can block the final flush. This deviation is -documented on [`super::WritebackFile::sync_all`] and the parent module's -Halt-safety section. diff --git a/docs/writeback-file.md b/docs/writeback-file.md deleted file mode 100644 index 0a566773..00000000 --- a/docs/writeback-file.md +++ /dev/null @@ -1,103 +0,0 @@ -# `WritebackFile` - -## Why it exists - -Large sequential writes (sweep, patch, mux on UHD-scale output) left to the -kernel's default writeback policy accumulate hundreds of megabytes of dirty -pages and then burst-flush, stalling subsequent writes for seconds at a -time. `WritebackFile` drives a continuous -[`super::writeback::WritebackPipeline`] that on Linux issues incremental -`sync_file_range` + `posix_fadvise(DONTNEED)` calls at 32 MB granularity so -dirty pages drain at the same rate they're produced. macOS and Windows fall -through to a no-op pipeline — their default cache policies have not been -shown to exhibit the same pathology for this access pattern. - -The wrapper also tracks the current file position to feed the pipeline with -progress + seek boundaries. See `super::writeback::linux` for the underlying -pathology and the strategy. - -## Platform split - -The platform-specific pieces of this wrapper — extent preallocation (Linux -`fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`, Windows no-op today) and the -durable-flush primitive (Linux/macOS `fsync`/`F_FULLFSYNC` wrapped in a -bounded syscall; Windows plain `FlushFileBuffers`, unbounded) — live in -per-OS sibling modules. The dispatch happens once at the bottom of `mod.rs` -via cfg-gated `mod` decls. No inline `#[cfg(target_os = "...")]` in the -business-logic above. - -## Write path - -Writes are direct passthrough to the underlying `File` (no writer thread, no -ring, no batching). Empirically a writer-thread architecture introduced a -~60% mux throughput regression on NFS bidirectional workloads; the -direct-passthrough write path is faster. The writeback pipeline still runs -(it's called inline from `write` / `write_all` / `seek`) so the -bounded-cache invariant on Linux is preserved. - -## Halt-safety - -`sync_all` runs the per-OS durable-flush primitive. On Linux/macOS it is -wrapped in [`crate::io::bounded::bounded_syscall`] with a 60 s deadline, so -a wedged NFS server cannot trap the muxer indefinitely on the final fsync. -Windows is a known deviation: its `durable_sync` calls `File::sync_all` -(`FlushFileBuffers`) directly and is NOT bounded — a wedged UNC/SMB share -can block the final flush there. - -`sync_all` drains in-flight writeback then issues a full fsync; use it in -place of `File::sync_all`. A bounded-fsync failure is returned as an `Err` -on BOTH platforms, so `Ok(())` means the flush completed and a caller -needing crash-consistency can treat it as a durability barrier. The three -causes are distinguishable by numeric code, because a caller should not -retry a lost worker the way it retries a timeout, and must not report a -user cancel as a failure: - -* [`E_SYNC_TIMEOUT`](crate::error::E_SYNC_TIMEOUT) — deadline expired -* [`E_HALTED`](crate::error::E_HALTED) — cancelled; - [`is_halt`](crate::error::is_halt) recognises it -* [`E_SYNC_WORKER_LOST`](crate::error::E_SYNC_WORKER_LOST) — the worker - thread died before reporting - -## `create_with_size_hint` - -Pre-reserves `size_bytes` of disk space via the platform's -extent-preallocation primitive (Linux `fallocate(KEEP_SIZE)`, macOS -`F_PREALLOCATE`; Windows has no equivalent, so it is a debug-logged no-op -there). 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 platforms without an -extent-preallocation primitive this is equivalent to `create` — the size -hint is dropped after a debug log. - -## Test coverage notes - -The `tests` submodule pins several behaviors with mutation-testing intent: - -* `write_returns_byte_count_and_advances_pos` — `write` must advance `pos` - by exactly the count the inner `File` reported (not `buf.len()`), guarding - against desync on a hypothetical partial write. -* `seek_to_current_position_is_noop_for_data` — sweep does a redundant - `seek(Current(pos))` before every write; the `p != self.pos` guard in - `Seek::seek` must keep that from being treated as a boundary. -* `open_preserves_existing_contents` — `open` uses `OpenOptions::write(true)` - with no truncate flag, distinct from `create`'s `File::create` (truncating) - path. -* `new_tracks_initial_position` — `WritebackFile::new` queries - `stream_position()` rather than hardcoding `pos = 0`, so a non-zero - starting offset (resumed/appended files) stays in sync. -* `seek_past_eof_creates_zero_hole` / `seek_from_end_resolves_against_length` - — `seek` forwards the `SeekFrom` variant as-is to the inner `File`, - preserving standard POSIX sparse-hole and end-relative semantics. -* `create_with_size_hint_does_not_inflate_logical_length` — the - preallocation hint reserves extents only; it must not grow the file's - *logical* length. -* `double_sync_all_is_idempotent` — `finalize` must tolerate being called - twice (explicit `sync_all` followed by `Drop`) without corrupting data. -* `writeback_chunk_constants_and_conversion` / - `writeback_chunk_env_override_branches` — pin the - `FREEMKV_WRITEBACK_CHUNK_MIB` parse/validate/convert logic in - `writeback_chunk_bytes`: in-range values convert MiB→bytes, and - zero/unparseable/over-max values all fall back to - `WRITEBACK_CHUNK_BYTES_DEFAULT`, with the max boundary itself accepted - (inclusive) and never overflowing `u64`. diff --git a/docs/writeback-linux.md b/docs/writeback-linux.md deleted file mode 100644 index 285981da..00000000 --- a/docs/writeback-linux.md +++ /dev/null @@ -1,70 +0,0 @@ -# `io::writeback::linux` — rationale - -## Pathology this fixes - -The kernel's default `vm.dirty_ratio` (~20 % of RAM) lets dirty pages -accumulate to hundreds of MB during a big sequential write, then bursts a -flush at 99 % disk utilisation. While the burst runs, app writes block on -the writeback queue — observed empirically as instantaneous speed dropping -from ~15 MB/s to ~1 MB/s every ~30 s during a Pass 1 sweep. - -## Strategy - -Every `chunk_bytes` of new sequential output, kick async writeback -(`SYNC_FILE_RANGE_WRITE`) on the just-completed chunk and finalise the -*previous* chunk via `WAIT_AFTER` + `posix_fadvise(DONTNEED)`. By the time -we finalise, that previous chunk has had 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. - -## `wait_after_with_timeout` fd lifetime / fd-reuse safety - -`worker_file` is an *owned* `File` (produced by `File::try_clone` at -pipeline construction). It is moved into the worker closure so the file -description stays alive for exactly as long as the worker thread lives — -even if the original `WritebackFile` is closed and the OS reuses its fd -number before the worker's syscall returns. - -`fallback_fd` is used only when `worker_file` is `None` (i.e. the -`try_clone` at construction failed). In that case the worker captures the -raw fd integer, which carries the original fd-reuse risk but is no worse -than the pre-fix behaviour. - -This delegates to `crate::io::bounded::bounded_syscall`, the generic -worker-thread + `recv_timeout` primitive, and just adapts it to the -WAIT_AFTER call shape: it returns `elapsed_ms` instead of the syscall's -`()`, and treats `WorkerLost` as a benign no-op to match the original -semantics. diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 476476c3..6573cda8 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -26,9 +26,8 @@ pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_BYTES) as u32; /// decrypt-on-read gate, the mux read paths, and key validation — never /// absolute `lba % 3`. /// -/// `saturating_sub` keeps `lba < unit_base` well-defined (clamped to 0, a -/// unit boundary) instead of a `wrapping_sub` mod-3 underflow trap. See -/// docs/aacs-content.md for the full rationale. +/// `saturating_sub` keeps `lba < unit_base` well-defined (clamped to 0, a unit boundary) +/// instead of a `wrapping_sub` mod-3 underflow trap. pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool { lba.saturating_sub(unit_base) .is_multiple_of(ALIGNED_UNIT_SECTORS) @@ -43,8 +42,8 @@ const TS_SYNC: u8 = 0x47; // ── Content decryption ────────────────────────────────────────────────────── -// HD-DVD `.evo` AACS-encrypted-unit flag: PES_scrambling_control at offset 20 -// (bits 5-4), spec-derived but UNVERIFIED. See docs/aacs-content.md. +// HD-DVD `.evo` AACS-encrypted-unit flag: PES_scrambling_control at offset 20 (bits 5-4), +// spec-derived but UNVERIFIED. const PS_SCRAMBLE_OFF: usize = 20; const PS_SCRAMBLE_MASK: u8 = 0x30; @@ -56,7 +55,7 @@ const PS_SCRAMBLE_MASK: u8 = 0x30; /// a real encrypted disc). /// /// Readable WITHOUT a key. Only meaningful when `unit` is read at the correct -/// clip-FILE-anchored boundary. See docs/aacs-content.md for the mux rationale. +/// clip-FILE-anchored boundary. pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> bool { use crate::disc::ContentFormat; if unit.len() < ALIGNED_UNIT_LEN { @@ -91,11 +90,10 @@ pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat) /// True when an aligned unit is flagged encrypted AND still looks scrambled /// (structure not yet restored) — genuine encrypted content NOT yet decrypted. /// -/// Composes [`aacs_unit_encrypted`] (the authoritative flag, which decryption -/// never rewrites) with an IDEMPOTENT "structure restored?" check, so callers -/// that may run twice over the same buffer (re-decrypt, sampling, diagnosis) -/// get a stable answer. Only meaningful at the clip-FILE-anchored boundary; -/// see docs/aacs-content.md for the full rationale. +/// Composes [`aacs_unit_encrypted`] (the authoritative flag, which decryption never rewrites) +/// with an IDEMPOTENT "structure restored?" check, so callers that may run twice over the same +/// buffer (re-decrypt, sampling, diagnosis) get a stable answer. Only meaningful at the +/// clip-FILE-anchored boundary. pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool { // "Still needs key" = flagged encrypted AND not structurally clean per the // ONE definition, [`is_clean`]'s min(E,4) proof floor. Never a second threshold: @@ -103,9 +101,8 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) aacs_unit_encrypted(unit, format) && !is_clean(unit, format) } -// Minimum synced 0x47 packets (of the ~31/unit) that PROVE a key opened a -// unit: an ABSOLUTE proof floor, NOT a proportion (false-pass ≈ C(31,4)*256^-4 -// ≈ 1e-5/unit). See docs/aacs-content.md#key_proof_packets for the full math. +// Minimum synced 0x47 packets (of the ~31/unit) that PROVE a key opened a unit: an ABSOLUTE +// proof floor, NOT a proportion (false-pass ≈ C(31,4)*256^-4 ≈ 1e-5/unit). const KEY_PROOF_PACKETS: usize = 4; /// Structural "did a key open this content unit?" — the pure, NO-CRYPTO signal @@ -123,9 +120,8 @@ pub fn is_clean(unit: &[u8], format: crate::disc::ContentFormat) -> bool { } } -// Structural "does this unit carry enough valid MPEG-TS to prove a key -// opened it?" (the mux's key-selection/verify signal): synced >= -// min(E, KEY_PROOF_PACKETS) over non-padding packets. See docs/aacs-content.md. +// Structural "does this unit carry enough valid MPEG-TS to prove a key opened it?" (the mux's +// key-selection/verify signal): synced >= min(E, KEY_PROOF_PACKETS) over non-padding packets. fn is_clean_ts(unit: &[u8]) -> bool { const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 let limit = ALIGNED_UNIT_LEN.min(unit.len()); @@ -172,9 +168,8 @@ pub fn ts_packet_total(unit: &[u8]) -> usize { unit.len() / BD_SOURCE_PACKET_BYTES } -// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): every 2048-byte pack -// in the 6144-byte unit must start with `00 00 01 BA`; UNVERIFIED against a -// real encrypted disc. See docs/aacs-content.md#is_clean_ps for validation notes. +// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): every 2048-byte pack in the 6144-byte +// unit must start with `00 00 01 BA`; UNVERIFIED against a real encrypted disc. fn is_clean_ps(unit: &[u8]) -> bool { if unit.len() < ALIGNED_UNIT_LEN { return false; @@ -198,10 +193,9 @@ fn is_clean_ps(unit: &[u8]) -> bool { /// TS (that's the separate [`is_clean_ts`] question, for key SELECTION or a /// read VERIFY). /// -/// Applies the key UNCONDITIONALLY: does NOT check the encrypted-flag, so the -/// CALLER must gate on [`aacs_unit_encrypted`] first. Block Key = -/// AES-128E(Kcu, seed) ⊕ seed, then AES-128-CBC decrypt bytes 16..6144 under -/// the AACS IV. See docs/aacs-content.md for the full rationale. +/// Applies the key UNCONDITIONALLY: does NOT check the encrypted-flag, so the CALLER must gate +/// on [`aacs_unit_encrypted`] first. Block Key = AES-128E(Kcu, seed) ⊕ seed, then AES-128-CBC +/// decrypt bytes 16..6144 under the AACS IV. pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { if unit.len() < ALIGNED_UNIT_LEN { return; @@ -233,9 +227,8 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { } } -/// Encrypt one AACS aligned unit (6144 bytes) IN PLACE — the exact inverse of -/// [`decrypt_unit`]. Caller must set the encrypted flag BEFORE calling and -/// check the returned bool. See docs/aacs-content.md for the full contract. +/// Encrypt one AACS aligned unit (6144 bytes) IN PLACE — the exact inverse of [`decrypt_unit`]. +/// Caller must set the encrypted flag BEFORE calling and check the returned bool. #[must_use = "returns false when the slice is too short to encrypt, leaving \ plaintext behind a flag that already says 'encrypted'"] pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { @@ -1094,8 +1087,8 @@ mod tests { // ── decrypt_bus: one key schedule per unit, not one per sector ───────── - // Regression pin: `decrypt_bus` used to expand a fresh AES-128 key per - // sector (3x/unit) for a loop-invariant key. See docs/aacs-content.md. + // Regression pin: `decrypt_bus` used to expand a fresh AES-128 key per sector (3x/unit) for + // a loop-invariant key. #[test] fn decrypt_bus_expands_the_read_data_key_once_per_unit() { use crate::aacs::crypto::KEY_EXPANSIONS; @@ -1223,7 +1216,6 @@ mod tests { // ── CBC decrypt: first-block uses fixed AACS IV ──────────────────────── // The published `iv0` bytes (`[C]` §2.1.2), INDEPENDENT of `crypto::AACS_IV`. - // See docs/aacs-content.md#iv0_published for why that independence matters. const IV0_PUBLISHED: [u8; 16] = [ 0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78, @@ -1498,8 +1490,8 @@ mod tests { // ── the encrypted-flag readers ──────────────────────────────────────── - // Guards a truncated fragment from being emitted as clear; reads ONLY the - // two CPI bits (`[BD]` §3.10.2, byte 0 bits 6-7). See docs/aacs-content.md. + // Guards a truncated fragment from being emitted as clear; reads ONLY the two CPI bits + // (`[BD]` §3.10.2, byte 0 bits 6-7). #[test] fn aacs_unit_seed_encrypted_reads_only_the_two_cpi_bits() { use crate::disc::ContentFormat::BdTs; diff --git a/src/aacs/crypto.rs b/src/aacs/crypto.rs index 7727832f..50e7407f 100644 --- a/src/aacs/crypto.rs +++ b/src/aacs/crypto.rs @@ -57,9 +57,8 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { out } -// AES-128-CBC encrypt in place under AACS_IV. Forward direction / exact -// inverse of aes_cbc_decrypt (`[C]` §2.1.2, AES-128CBCE). -// See docs/aacs-crypto.md — aes_cbc_encrypt (precondition, single key schedule). +// AES-128-CBC encrypt in place under AACS_IV. Forward direction / exact inverse of +// aes_cbc_decrypt (`[C]` §2.1.2, AES-128CBCE). pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) { debug_assert!( data.len().is_multiple_of(16), @@ -83,7 +82,6 @@ pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) { } // AES-128-CBC decrypt in-place under AACS_IV (`[C]` §2.1.2, AES-128CBCD). -// See docs/aacs-crypto.md — aes_cbc_decrypt (precondition, doc-orphaning history). pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) { debug_assert!( data.len().is_multiple_of(16), @@ -92,9 +90,8 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) { cbc_decrypt_blocks(&new_cipher(key), data); } -// AES-128-CBC decrypt in place under AACS_IV with an already-expanded key -// schedule. Split out of aes_cbc_decrypt for callers that share one key -// across several regions. See docs/aacs-crypto.md — cbc_decrypt_blocks. +// AES-128-CBC decrypt in place under AACS_IV with an already-expanded key schedule. Split out +// of aes_cbc_decrypt for callers that share one key across several regions. pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) { let num_blocks = data.len() / 16; // Process blocks in reverse to avoid clobbering ciphertext needed for XOR @@ -117,9 +114,8 @@ pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) { } } -// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2 (`[C]` §2.1.3, uses AES-128D). -// Used by the Media Key Variant chain to derive Kvn and Kvu. -// See docs/aacs-crypto.md — aes_g. +// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2 (`[C]` §2.1.3, uses AES-128D). Used by the Media Key +// Variant chain to derive Kvn and Kvu. pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] { let mut out = aes_ecb_decrypt(x1, x2); for i in 0..16 { @@ -133,9 +129,8 @@ pub(crate) const AESG3_SEED: [u8; 16] = [ 0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9, ]; -// AACS-G3: derive a subkey from a parent key (`[C]` §3.2.2, Triple AES -// Generator). Shared with super::variant so both SD-tree walks stay -// byte-identical. See docs/aacs-crypto.md — aesg3. +// AACS-G3: derive a subkey from a parent key (`[C]` §3.2.2, Triple AES Generator). Shared with +// super::variant so both SD-tree walks stay byte-identical. pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] { let mut seed = AESG3_SEED; seed[15] = seed[15].wrapping_add(inc); @@ -150,9 +145,8 @@ pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] { mod tests { use super::*; - // s0 transcribed independently from `[C]` §3.2.2, not read from AESG3_SEED, - // so this can't assert the production constant against itself. - // See docs/aacs-crypto.md — test: S0. + // s0 transcribed independently from `[C]` §3.2.2, not read from AESG3_SEED, so this can't + // assert the production constant against itself. const S0: [u8; 16] = [ 0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9, @@ -166,9 +160,8 @@ mod tests { 0xF0, ]; - // aesg3 is the SD-tree node function; a wrong `^` or a constant body - // would derive a plausible-but-wrong Processing Key. Pinned via the spec - // relation (see docs/aacs-crypto.md for the full derivation). + // aesg3 is the SD-tree node function; a wrong `^` or a constant body would derive a + // plausible-but-wrong Processing Key. Pinned via the spec relation. #[test] fn aesg3_inverts_to_the_spec_seed_under_aes_encrypt() { for inc in 0u8..=2 { @@ -191,9 +184,8 @@ mod tests { } } - // The Triple Generator's three outputs are one node's two children plus - // its Processing Key; if `inc` were ignored a descent would revisit its - // own parent. See docs/aacs-crypto.md — test: three distinct subkeys. + // The Triple Generator's three outputs are one node's two children plus its Processing Key; + // if `inc` were ignored a descent would revisit its own parent. #[test] fn aesg3_yields_three_distinct_subkeys_for_the_three_increments() { let left = aesg3(&K, 0); diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index b7d7cdeb..5bafe019 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -14,9 +14,6 @@ use super::types::*; /// If you hold a **device-node label** at unknown tree depth (not a /// terminal PK), use [`derive_media_key_from_dk`] instead — only that path /// walks the Subset-Difference tree. -/// -/// See `docs/aacs-derive.md` for the MKB record-type layout and the -/// performance case for the direct PK × cvalue scan. pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> { let mk_dv = mkb_find_mk_dv(mkb)?; let uvs = mkb_find_subdiff_records(mkb)?; @@ -24,9 +21,8 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv) } -// Core terminal-PK table scan: each PK tried directly against every -// (uv, cvalue) pair, no tree descent. Factored out so reproduction -// harnesses can drive it with explicit tables (see docs/aacs-derive.md). +// Core terminal-PK table scan: each PK tried directly against every (uv, cvalue) pair, no tree +// descent. Factored out so reproduction harnesses can drive it with explicit tables. pub(crate) fn try_pk_against_tables( processing_keys: &[[u8; 16]], uvs: &[u8], @@ -239,8 +235,6 @@ pub fn derive_media_key_and_pk_from_dk( /// the key across all discs — and resolves a gate-passing `node`. Returns a /// [`DeviceKey`] ready to bank and reuse on every future disc via /// [`derive_media_key_from_dk`]. `None` if the key does not apply to this MKB. -/// -/// See `docs/aacs-derive.md` for the search strategy and its cost. pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option<DeviceKey> { let mk_dv = mkb_find_mk_dv(mkb)?; let uvs = mkb_find_subdiff_records(mkb)?; @@ -462,9 +456,8 @@ impl std::fmt::Debug for ResolvedChain { /// `VUK → UKs`, or `UK → itself`, parsing `Unit_Key_RO.inf` at the version the /// disc's MKB declares so a multi-CPS disc yields all its unit keys. /// -/// PURE DERIVATION: no sampling, no validation, no position recovery. Returns -/// `None` only when derivation itself cannot proceed. See `docs/aacs-derive.md` -/// for the composed primitives and the full `None` conditions. +/// PURE DERIVATION: no sampling, no validation, no position recovery. Returns `None` only when +/// derivation itself cannot proceed. pub fn resolve_candidate( candidate: &KeyCandidate, mkb: &[u8], @@ -541,9 +534,8 @@ mod resolve_candidate_tests { use super::*; use crate::aacs::crypto::aes_ecb_encrypt; - // km_verifies gates every candidate Media Key; mutation testing found - // `-> true` surviving all 2,556 tests, i.e. unverified verification. - // See docs/aacs-derive.md for how the fixture inverts the AES relation. + // km_verifies gates every candidate Media Key; mutation testing found `-> true` surviving + // all 2,556 tests, i.e. unverified verification. #[test] fn km_verifies_accepts_only_the_key_its_record_was_built_for() { use crate::aacs::mkb::mkb_find_mk_dv; @@ -987,9 +979,9 @@ mod resolve_candidate_tests { } } -// Device-key POSITION recovery and the MKB probe accessors. No published AACS -// test vectors exist; the relations (`[C]` §3.2.3-§3.2.5) are invertible, so -// `plant_mkb` below builds a valid MKB for a CHOSEN key. See docs/aacs-derive.md. +// Device-key POSITION recovery and the MKB probe accessors. No published AACS test vectors +// exist; the relations (`[C]` §3.2.3-§3.2.5) are invertible, so `plant_mkb` below builds a +// valid MKB for a CHOSEN key. #[cfg(test)] mod position_recovery_tests { use super::*; @@ -1023,9 +1015,8 @@ mod position_recovery_tests { u_mask_shift: u8, } - // Build the fixture by inverting the AACS relations. uv/u_mask_shift are - // chosen so a gating device node exists (see docs/aacs-derive.md for the - // bit-level derivation); uv stays under 0x10000 since DeviceKey::node is u16. + // Build the fixture by inverting the AACS relations. uv/u_mask_shift are chosen so a gating + // device node exists; uv stays under 0x10000 since DeviceKey::node is u16. fn plant_mkb() -> Planted { let dkey: [u8; 16] = [ 0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, diff --git a/src/aacs/index_select.rs b/src/aacs/index_select.rs index 0cbd7dab..03623dea 100644 --- a/src/aacs/index_select.rs +++ b/src/aacs/index_select.rs @@ -5,8 +5,6 @@ //! index are kept, others dropped; everything outside a segment is ordinary (index-0) //! content. This module owns that classification only — no I/O, no keys, no cipher. //! The decrypt pipeline consumes the [`UnitDisposition`] it returns. -//! -//! See docs/index-select.md for where the resolved index ([`resolve_disc_index`]) comes from. use super::segment::{Segment, segment_for_unit}; use super::types::UnitKey; diff --git a/src/aacs/inf.rs b/src/aacs/inf.rs index 39728316..c3b8dd38 100644 --- a/src/aacs/inf.rs +++ b/src/aacs/inf.rs @@ -22,8 +22,7 @@ pub struct UnitKeyFile { pub title_cps_unit: Vec<u16>, } -// Hand-written Debug that redacts the ENCRYPTED CPS unit keys a derive would -// print verbatim. See docs/inf.md — UnitKeyFile's hand-written Debug. +// Hand-written Debug that redacts the ENCRYPTED CPS unit keys a derive would print verbatim. impl std::fmt::Debug for UnitKeyFile { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("UnitKeyFile") @@ -198,8 +197,6 @@ const VTKF_MAX_ENTRIES: usize = 64; /// `BIFO` bit 7 (`AV_FLG`): set = this slot carries an available title key. const VTKF_AV_FLG: u8 = 0x80; -// See docs/inf.md — parse_vtkf: byte-exact real-disc verification and why -// the prior 32-byte stride corrupted multi-key VTKFs. /// Parse an HD DVD `VTKF%%%.AACS` into the SAME [`UnitKeyFile`] a BD/UHD /// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` → /// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change. @@ -396,9 +393,8 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> { mod vtkf_tests { use super::*; - // Synthesize a `VTKF%%%.AACS`: magic, BE32 size, playlist name, reserved - // to 0x80, 64 entry slots (first `keys.len()` present), MAC trailer. - // See docs/inf.md — synth_vtkf test helper. + // Synthesize a `VTKF%%%.AACS`: magic, BE32 size, playlist name, reserved to 0x80, 64 entry + // slots (first `keys.len()` present), MAC trailer. fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> { const FILE_LEN: usize = 2480; let mut v = Vec::new(); @@ -504,8 +500,7 @@ mod vtkf_tests { assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc)); } - // Sentinel byte 0xD5 = decimal 213: a derived Debug would render it in - // decimal. See docs/inf.md — unit_key_file_debug_is_redacted test. + // Sentinel byte 0xD5 = decimal 213: a derived Debug would render it in decimal. #[test] fn unit_key_file_debug_is_redacted() { let f = UnitKeyFile { @@ -572,8 +567,7 @@ mod read_mkb_tests { } } - // Pins the CONTENT: concatenated pack payloads, in pack order, byte for - // byte. See docs/inf.md — read_mkb_from_drive_returns_the_concatenated… + // Pins the CONTENT: concatenated pack payloads, in pack order, byte for byte. #[test] fn read_mkb_from_drive_returns_the_concatenated_pack_payload() { let pack0: Vec<u8> = (0..600u32).map(|i| (i % 251) as u8).collect(); @@ -613,8 +607,8 @@ mod read_mkb_tests { } } - // Pins the WHOLE 12-byte CDB (MMC-6 READ DISC STRUCTURE, AACS MKB - // format) so no field can drift. See docs/inf.md — this test's layout. + // Pins the WHOLE 12-byte CDB (MMC-6 READ DISC STRUCTURE, AACS MKB format) so no field can + // drift. #[test] fn read_mkb_from_drive_issues_the_exact_mmc_cdb_for_each_pack() { let mut drive = MkbDrive { @@ -648,8 +642,8 @@ mod read_mkb_tests { } } - // A full 32768-byte pack must come back whole; small payloads elsewhere - // never exercise this bound. See docs/inf.md — this test. + // A full 32768-byte pack must come back whole; small payloads elsewhere never exercise this + // bound. #[test] fn read_mkb_from_drive_accepts_a_full_size_pack() { let full: Vec<u8> = (0..32768u32).map(|i| (i % 251) as u8).collect(); @@ -689,8 +683,7 @@ mod read_mkb_tests { assert!(mkb == vec![0xABu8; 32], "and the bytes are pack 1's"); } - // A drive-declared length past the 32772-byte buffer must not be - // trusted. See docs/inf.md — this test. + // A drive-declared length past the 32772-byte buffer must not be trusted. #[test] fn read_mkb_from_drive_ignores_a_pack_declaring_more_than_the_buffer_holds() { /// Pack 0 is honest; pack 1 declares a 60000-byte payload it never sent. diff --git a/src/aacs/mkb.rs b/src/aacs/mkb.rs index a6cc845b..d3351d9b 100644 --- a/src/aacs/mkb.rs +++ b/src/aacs/mkb.rs @@ -60,9 +60,8 @@ pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> { .collect() } -// THE single MKB record-framing walker: 4-byte header (type + BE24 length) -// then body, stopping at the `00 000000` end marker. `walk_mkb` and every -// other MKB walk are built on this. See docs/mkb.md — record-walker consolidation. +// THE single MKB record-framing walker: 4-byte header (type + BE24 length) then body, stopping +// at the `00 000000` end marker. `walk_mkb` and every other MKB walk are built on this. pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ { let mut pos = 0usize; std::iter::from_fn(move || { @@ -187,9 +186,8 @@ pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> { find_record_body(mkb, 0x04) } -// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. -// 0x05 is the canonical cvalue table on both AACS 1.0 and 2.x; 0x07 is a -// smaller fallback only. See docs/mkb.md — cvalue record selection rationale. +// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. 0x05 is +// the canonical cvalue table on both AACS 1.0 and 2.x; 0x07 is a smaller fallback only. pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> { if let Some(body) = find_record_body(mkb, 0x05) { return Some(body); @@ -436,9 +434,8 @@ mod tests { ); } - // BE24 length field (all THREE bytes): a walker that dropped the high byte - // would mis-frame every large record (real cvalue/variant tables). - // See docs/mkb.md — BE24 length field test. + // BE24 length field (all THREE bytes): a walker that dropped the high byte would mis-frame + // every large record (real cvalue/variant tables). #[test] fn mkb_records_honors_the_high_byte_of_the_be24_length() { const TOTAL: usize = 0x0001_0004; // 65_540 — high byte 0x01 @@ -461,9 +458,8 @@ mod tests { ); } - // Header-only records and the exact end marker: `rec_len == 4` is a - // well-formed HEADER-ONLY record, including one at the buffer end. - // See docs/mkb.md — header-only record test. + // Header-only records and the exact end marker: `rec_len == 4` is a well-formed HEADER-ONLY + // record, including one at the buffer end. #[test] fn mkb_records_yields_a_header_only_record_at_the_buffer_end() { let mut mkb = rec(REC_TYPE_AND_VERSION, &[0xAA, 0xBB]); @@ -504,9 +500,8 @@ mod tests { assert_eq!(recs[1].body, vec![0x55; 16]); } - // `mkb_type_raw` reports the 32-bit MKBType field verbatim, including - // unrecognised values; all four bytes must come from the record body. - // See docs/mkb.md — MKBType raw-bytes test. + // `mkb_type_raw` reports the 32-bit MKBType field verbatim, including unrecognised values; + // all four bytes must come from the record body. #[test] fn mkb_type_raw_reads_all_four_body_bytes() { const RAW: u32 = 0xDEAD_BEEF; diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index c44dda86..d281c8f0 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -4,9 +4,6 @@ //! handshake (device_keys + MKB → Media Key → + Volume ID → VUK, fallback). //! The VUK decrypts title keys from AACS/Unit_Key_RO.inf; title keys decrypt //! m2ts stream content (AES-128-CBC). -//! -//! See docs/aacs.md ("src/aacs/mod.rs module notes") for the KEYDB.cfg line -//! format and the `[TAG] §x.y` spec-provenance citation scheme used below. pub mod content; pub mod crypto; @@ -29,8 +26,6 @@ pub mod variant; /// Each [`AacsRole`] resolves to an ordered candidate list, walked by /// [`read_first`] to the first that reads, so every reader shares one source /// of truth for disc_hash / MKB / VID. -/// -/// See docs/aacs.md for HD DVD directory-name/file details. pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf"; pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf"; pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf"; @@ -52,9 +47,8 @@ pub enum AacsRole { ContentCert, } -// The HD DVD AACS directory in a parsed UDF tree, if present. Identified -// structurally (name ends `!`, contains MKBROM.AACS), not by a hardcoded -// name. See docs/aacs.md for observed real directory names. +// The HD DVD AACS directory in a parsed UDF tree, if present. Identified structurally (name +// ends `!`, contains MKBROM.AACS), not by a hardcoded name. pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> { udf.root.entries.iter().find(|e| { e.is_dir @@ -319,9 +313,8 @@ mod tests { ); } - // Both the `!`-suffix AND MKBROM.AACS presence are required (conjunction, - // not disjunction) — else the HD DVD path resolves key files under a dir - // holding none. See docs/aacs.md for the full rationale. + // Both the `!`-suffix AND MKBROM.AACS presence are required (conjunction, not disjunction) + // — else the HD DVD path resolves key files under a dir holding none. #[test] fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() { use crate::udf::fixture::*; diff --git a/src/aacs/provider.rs b/src/aacs/provider.rs index b2492a5c..2540afea 100644 --- a/src/aacs/provider.rs +++ b/src/aacs/provider.rs @@ -7,7 +7,6 @@ //! in provider-array order. //! //! Default impls return empty/`None`; calls may block, so cache per-disc. -//! See docs/key-provider.md for rationale and `host_certs`'s caveat. use super::types::{DeviceKey, DiscEntry, HostCert}; @@ -61,9 +60,8 @@ pub trait KeyProvider: Send + Sync { } } -// Resolver-side helper that aggregates KeyProvider material across a -// provider array (union-and-dedup for bulk methods, first-hit for -// disc-keyed lookups). See docs/key-provider.md for the policy detail. +// Resolver-side helper that aggregates KeyProvider material across a provider array +// (union-and-dedup for bulk methods, first-hit for disc-keyed lookups). pub(crate) struct Providers<'a>(pub &'a [&'a dyn KeyProvider]); impl Providers<'_> { @@ -112,9 +110,9 @@ impl Providers<'_> { } } -// Bridges a single caller-supplied key's raw material into a KeyProvider, -// for `Disc::decrypt_with`. Fills only its own level (DK/PK/MK/VUK) so the -// resolver runs the matching path; see docs/key-provider.md for details. +// Bridges a single caller-supplied key's raw material into a KeyProvider, for +// `Disc::decrypt_with`. Fills only its own level (DK/PK/MK/VUK) so the resolver runs the +// matching path. pub(crate) struct SuppliedKey { pub device_keys: Vec<DeviceKey>, pub processing_keys: Vec<[u8; 16]>, @@ -326,9 +324,8 @@ mod tests { assert_eq!(got.disc_hash, "vid-a"); } - // `host_certs` unions across the provider array, unwired into the - // handshake today (see docs/key-provider.md); asserts full concatenation - // in array order since HostCert has no Ord/Hash to dedup. + // `host_certs` unions across the provider array, unwired into the handshake today; asserts + // full concatenation in array order since HostCert has no Ord/Hash to dedup. #[test] fn providers_host_certs_unions_every_providers_certs_in_array_order() { struct Certs(Vec<HostCert>); diff --git a/src/aacs/resolve.rs b/src/aacs/resolve.rs index 09c046d2..d0f1828f 100644 --- a/src/aacs/resolve.rs +++ b/src/aacs/resolve.rs @@ -269,9 +269,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> { None } -// Classical (single-stage Media Key derivation) path chain, used by both -// V10 and V20. See docs/aacs-resolve.md#resolve_keys_classical for the -// full root-of-trust -> per-disc-leaf path list. +// Classical (single-stage Media Key derivation) path chain, used by both V10 and V20. fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Option<ResolvedKeys> { let bus_encryption = ctx .content_cert @@ -425,9 +423,8 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt /// verifies when AES-128-ECB-D(mk, mk_dv) starts with it. const MK_VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; -// Path 2.5's MK-pool selection rule: a Media Key must be the EXACTLY ONE -// distinct verifying candidate (dupes across providers are one candidate). -// See docs/aacs-resolve.md#unique_verifying_mk for the ambiguity rationale. +// Path 2.5's MK-pool selection rule: a Media Key must be the EXACTLY ONE distinct verifying +// candidate (dupes across providers are one candidate). fn unique_verifying_mk(mks: &[[u8; 16]], verifies: impl Fn(&[u8; 16]) -> bool) -> Option<[u8; 16]> { let mut hits: Vec<[u8; 16]> = Vec::new(); for mk in mks { @@ -1248,9 +1245,9 @@ mod tests { ); } - // Path 2.5's ambiguity guard: >1 distinct pooled MK verifying must yield - // NO key, never a pick (a wrong Km derives a wrong VUK that decrypts to - // garbage rather than failing loudly). See docs/aacs-resolve.md. + // Path 2.5's ambiguity guard: >1 distinct pooled MK verifying must yield NO key, never a + // pick (a wrong Km derives a wrong VUK that decrypts to garbage rather than failing + // loudly). #[test] fn mk_pool_ambiguity_bails_rather_than_picking_a_media_key() { let a = [0xAAu8; 16]; @@ -1810,9 +1807,9 @@ mod tests { assert_eq!(r.vuk, Some(derive_vuk(&mk, &vid))); } - // Zero VID is the "never read" sentinel gating paths 1/3; treating it as - // real derives a WRONG-but-valid VUK (silent garbage), treating a real - // VID as absent skips a resolvable disc. See docs/aacs-resolve.md. + // Zero VID is the "never read" sentinel gating paths 1/3; treating it as real derives a + // WRONG-but-valid VUK (silent garbage), treating a real VID as absent skips a resolvable + // disc. #[test] fn resolve_keys_v21_treats_the_all_zero_volume_id_as_no_vid() { let uk_ro = minimal_unit_key_ro(); @@ -2061,9 +2058,8 @@ mod tests { ); } - // A non-zero VID present but resolution still fails must classify - // NoMaterial, never VidUnavailable, regardless of how much derivation - // material is on hand. See docs/aacs-resolve.md for the full rationale. + // A non-zero VID present but resolution still fails must classify NoMaterial, never + // VidUnavailable, regardless of how much derivation material is on hand. #[test] fn classify_vid_present_with_material_is_no_material_not_vid() { let prov = material_provider(vec![one_device_key()], vec![[0u8; 16]]); diff --git a/src/aacs/segment.rs b/src/aacs/segment.rs index d843b34a..691ca792 100644 --- a/src/aacs/segment.rs +++ b/src/aacs/segment.rs @@ -12,7 +12,6 @@ //! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1) //! u32 start_spn | u32 end_spn (source-packet numbers, inclusive) //! ``` -//! See docs/segment.md — terminology, measurements, interleave rationale. /// Fixed size of one `IndividualSegment.tbl` record. pub const SEGMENT_RECORD_LEN: usize = 16; @@ -80,10 +79,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 { /// The forensic segment an AACS aligned unit belongs to, if any, given the /// unit's clip-relative byte offset. /// -/// A unit overlapping a segment must be opened with that segment's index key -/// (selected by `index`), not the CPS Unit Key — see docs/segment.md for why. -/// `None` means the unit is ordinary content and a miss on it is a plain -/// Unit-Key miss. +/// A unit overlapping a segment must be opened with that segment's index key (selected by +/// `index`), not the CPS Unit Key. /// /// Tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a unit that /// only partly overlaps a segment edge still counts as forensic. @@ -147,11 +144,10 @@ pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Opti /// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map. /// -/// Each forensic segment's clip-relative source-packet span becomes an absolute -/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`). -/// Ranges outside every segment are left for the map's default (the Unit Key). -/// A segment straddling a UDF extent boundary is skipped rather than emitting -/// a wrong span. See docs/segment.md for the interleave-decode rationale. +/// Each forensic segment's clip-relative source-packet span becomes an absolute LBA range +/// tagged with the key its `index` selects (via `index_to_key_idx`). Ranges outside every +/// segment are left for the map's default (the Unit Key). A segment straddling a UDF extent +/// boundary is skipped rather than emitting a wrong span. /// /// Feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges) /// with the Unit-Key index as the default. diff --git a/src/aacs/segment_key.rs b/src/aacs/segment_key.rs index 12bdd631..8f0c8397 100644 --- a/src/aacs/segment_key.rs +++ b/src/aacs/segment_key.rs @@ -11,7 +11,6 @@ //! record[index_space] (record_size bytes each) //! ``` //! Record layout beyond an 8-byte sub-header is not yet reversed. -// See docs/segment-key.md — confirmed disc measurements and reversing notes. /// Bytes of the fixed file header. pub const HEADER_LEN: usize = 8; diff --git a/src/aacs/trace.rs b/src/aacs/trace.rs index 49fbcea9..014dc415 100644 --- a/src/aacs/trace.rs +++ b/src/aacs/trace.rs @@ -1,9 +1,8 @@ //! Structured resolution trace — what the unlock + key-resolution attempt did. //! -//! No user-facing English: every step's STATE is a typed enum variant that -//! applications render into localized text. The `who` field of each step is -//! a stable identifier (e.g. a `label()`/`name()`), never prose, carried -//! verbatim. See docs/aacs-trace.md for full rationale. +//! No user-facing English: every step's STATE is a typed enum variant that applications render +//! into localized text. The `who` field of each step is a stable identifier (e.g. a +//! `label()`/`name()`), never prose, carried verbatim. /// The full trace of a resolution attempt: the unlock phase, then the /// key-resolution phase. diff --git a/src/aacs/types.rs b/src/aacs/types.rs index 3315751f..9facd5ed 100644 --- a/src/aacs/types.rs +++ b/src/aacs/types.rs @@ -191,9 +191,8 @@ impl std::fmt::Debug for DiscEntry { mod unit_key_tests { use super::*; - // Pinned against the two named constructors: `UnitKey::new` is the - // ordinary key, `UnitKey::forensic` is an index key. - // See docs/aacs.md — `UnitKey::is_default_index` test rationale. + // Pinned against the two named constructors: `UnitKey::new` is the ordinary key, + // `UnitKey::forensic` is an index key. #[test] fn is_default_index_separates_the_two_constructors() { let ordinary = UnitKey::new(0, [0xAA; 16]); @@ -213,7 +212,6 @@ mod unit_key_tests { } // Must agree with `resolve_disc_index`, the one consumer of `index_number`. - // See docs/aacs.md — `UnitKey::is_default_index` test rationale. #[test] fn is_default_index_agrees_with_the_forensic_index_resolver() { use crate::aacs::index_select::resolve_disc_index; diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index 6813ce4d..aeecf9aa 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -1,12 +1,10 @@ //! AACS Media Key Variant chain. //! -//! On AACS 2.1 the Media Key derivation gains a second stage on top of -//! the classical subset-difference walk: the walk yields a Media Key -//! Precursor (Kmp), combined with disc VKD and a per-licensee KCD -//! constant to produce the Media Key. Entry point: -//! [`derive_media_key_variant`] (`Kp -> Km`); `Kp` comes from -//! [`walk_processing_key`]. No `0x2d`/`0x2f`/`0x0c` records falls back -//! to [`super::derive`]. See docs/variant.md for layout/gate notes. +//! On AACS 2.1 the Media Key derivation gains a second stage on top of the classical +//! subset-difference walk: the walk yields a Media Key Precursor (Kmp), combined with disc VKD +//! and a per-licensee KCD constant to produce the Media Key. Entry point: +//! [`derive_media_key_variant`] (`Kp -> Km`); `Kp` comes from [`walk_processing_key`]. No +//! `0x2d`/`0x2f`/`0x0c` records falls back to [`super::derive`]. //! //! ```text //! Kmp = AES-128D(Kp, C) XOR uv @@ -21,12 +19,9 @@ use super::crypto::{aes_ecb_decrypt, aes_g}; use super::mkb::*; use super::types::DeviceKey; -// See docs/variant.md — MKB records this chain selects. - // ── Public constants ────────────────────────────────────────────────────── /// Zero placeholder KCD, NOT real key material — PER-LICENSEE. -/// See docs/variant.md — KEY_CORRECTION_DATA for the consequence. const KEY_CORRECTION_DATA: [u8; 16] = [0u8; 16]; // ── MKB record walking ──────────────────────────────────────────────────── @@ -45,9 +40,8 @@ pub fn is_variant_mkb(records: &[MkbRecord]) -> bool { .any(|r| matches!(r.rec_type, REC_VARIANT_DATA_AND_NONCE | REC_VKD_TABLE)) } -/// Body of the `0x2d` record: `VARIANTS` table + trailing 16-byte Nonce. -/// NOT the C used for `Kmp` (that's `0x0c`'s per-slot block). -/// See docs/variant.md — variant_data_record for the measured layout. +/// Body of the `0x2d` record: `VARIANTS` table + trailing 16-byte Nonce. NOT the C used for +/// `Kmp` (that's `0x0c`'s per-slot block). pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> { records .iter() @@ -73,8 +67,7 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> { Some(out) } -/// The Variant Key Data (VKD) table — record type `0x2f`. -/// Disc-public data; see docs/variant.md — variant_key_data for sizing. +/// The Variant Key Data (VKD) table — record type `0x2f`. Disc-public data. pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> { records .iter() @@ -84,8 +77,7 @@ pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> { // ── Subset-difference walk that exposes (Kp, uv) ────────────────────────── -// Shared with the classical walk in super::derive to keep the SD tree -// byte-identical. See docs/variant.md — subset-difference walk sharing. +// Shared with the classical walk in super::derive to keep the SD tree byte-identical. use super::derive::{calc_pk_from_dk, calc_v_mask}; /// Outcome of a subset-difference walk against an MKB. Carries the @@ -130,11 +122,10 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> { /// Walk an MKB and return the first `(Kp, uv, cvalue)` that /// `device_keys` covers. Returns `None` if no DK walks any uv. /// -/// This is the AACS-2.1 **variant** walk (classical walk: [`super::derive`]). -/// Kept separate on purpose — different cvalue-record order and input -/// framing; do NOT route the classical DK path through this function, or -/// the `0x07`-first selection picks the wrong cvalue and returns `None`. -/// See docs/variant.md — walk_processing_key for the full rationale. +/// This is the AACS-2.1 **variant** walk (classical walk: [`super::derive`]). Kept separate on +/// purpose — different cvalue-record order and input framing; do NOT route the classical DK +/// path through this function, or the `0x07`-first selection picks the wrong cvalue and returns +/// `None`. pub fn walk_processing_key( records: &[MkbRecord], device_keys: &[DeviceKey], @@ -279,9 +270,8 @@ impl std::error::Error for MediaKeyVariantError {} // ── Chain ───────────────────────────────────────────────────────────────── -/// Look up the per-slot `VARIANTS[sd_slot_index]` (leading bytes of the -/// `0x2d` body, before the tail Nonce — see [`variant_nonce`]). -/// See docs/variant.md — variants_for_uv for the measured layout. +/// Look up the per-slot `VARIANTS[sd_slot_index]` (leading bytes of the `0x2d` body, before the +/// tail Nonce — see [`variant_nonce`]). fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> { let body = variant_data_record(records)?; // VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at the @@ -409,13 +399,11 @@ fn variant_km_for_slot( /// Derive the AACS 2.1 variant **Media Key** from a Processing Key. /// -/// The one deterministic `Kp → Km` derivation for a variant MKB: tries -/// `pk` (which arrives without its slot) against every slot and returns -/// the Km for the slot whose full chain passes the Verify-Media-Key -/// record, so an unverified key is never returned. VID-free — derive the -/// VUK via [`super::derive::derive_vuk`]; `Kp` comes from -/// [`walk_processing_key`]. Errors: `NotVariantMkb`/`MkbIncomplete`/ -/// `ProcessingKeyUnavailable`; see docs/variant.md — derive_media_key_variant. +/// The one deterministic `Kp → Km` derivation for a variant MKB: tries `pk` (which arrives +/// without its slot) against every slot and returns the Km for the slot whose full chain passes +/// the Verify-Media-Key record, so an unverified key is never returned. VID-free — derive the +/// VUK via [`super::derive::derive_vuk`]; `Kp` comes from [`walk_processing_key`]. Errors: +/// `NotVariantMkb`/`MkbIncomplete`/ `ProcessingKeyUnavailable` pub fn derive_media_key_variant( mkb_records: &[MkbRecord], pk: &[u8; 16], @@ -464,10 +452,8 @@ pub fn derive_media_key_variant( /// device-key walk and the on-MKB `VARIANTS[uv]` lookup; the MKB still /// supplies the Nonce, VKD table, and Verify-Media-Key value. /// -/// Returns `(Km, Kvu)`. The terminal gate is identical to -/// [`derive_media_key_variant`]: a wrong input returns -/// [`MediaKeyVariantError::MediaKeyVerifyFailed`]. See docs/variant.md — -/// media_key_variant_from_kp for the [`KEY_CORRECTION_DATA`] caveat. +/// Returns `(Km, Kvu)`. The terminal gate is identical to [`derive_media_key_variant`]: a wrong +/// input returns [`MediaKeyVariantError::MediaKeyVerifyFailed`]. pub fn media_key_variant_from_kp( kp: &[u8; 16], c_block: &[u8; 16], @@ -693,9 +679,8 @@ mod tests { // ── Fixture construction ── - /// Build a synthetic variant MKB + DK walking its one SD slot. `kmp15` - /// picks `Kmp[15]` (`0x02`=SoftCorrection, `0x04`=OnlineChallenge, - /// `0x00`=neither). See docs/variant.md — synthetic_variant_setup. + /// Build a synthetic variant MKB + DK walking its one SD slot. `kmp15` picks `Kmp[15]` + /// (`0x02`=SoftCorrection, `0x04`=OnlineChallenge, `0x00`=neither). fn synthetic_variant_setup(kmp15: u8) -> (Vec<MkbRecord>, DeviceKey, [u8; 16], [u8; 16]) { use crate::aacs::crypto::aes_ecb_encrypt; @@ -1095,7 +1080,6 @@ mod tests { } /// Build a variant MKB by inverting the 2.1 chain for a CHOSEN `(Kp, Km)`. - /// See docs/variant.md — Test fixture: plant_variant_mkb. fn plant_variant_mkb() -> PlantedVariant { use crate::aacs::crypto::{aes_ecb_encrypt, aes_g}; @@ -1214,8 +1198,7 @@ mod tests { ); } - /// THE happy path: a covering Processing Key must derive the planted - /// Media Key. See docs/variant.md — Test: ..._for_a_covering_kp. + /// THE happy path: a covering Processing Key must derive the planted Media Key. #[test] fn variant_chain_derives_the_planted_media_key_for_a_covering_kp() { let p = plant_variant_mkb(); @@ -1242,8 +1225,7 @@ mod tests { assert_ne!(got, Ok(p.km)); } - /// `mkb_find_mk_dv` must supply the ACTUAL `0x86` bytes, not a fixed - /// block. See docs/variant.md — Test: mkb_find_mk_dv_returns_.... + /// `mkb_find_mk_dv` must supply the ACTUAL `0x86` bytes, not a fixed block. #[test] fn mkb_find_mk_dv_returns_the_verify_records_actual_bytes() { let p = plant_variant_mkb(); @@ -1269,8 +1251,7 @@ mod tests { ); } - /// `variants_for_uv` must read the REAL `VARIANTS[slot]`, not a - /// constant. See docs/variant.md — Test: variants_for_uv_reads_the_.... + /// `variants_for_uv` must read the REAL `VARIANTS[slot]`, not a constant. #[test] fn variants_for_uv_reads_the_planted_table_entry_that_selects_the_vkd() { let p = plant_variant_mkb(); @@ -1323,8 +1304,7 @@ mod tests { assert_eq!(variant_nonce(&recs), Some(nonce), "the Nonce is the tail"); } - /// `variant_uv_slots` must drop `uv == 0` and `u_mask_shift >= 32` - /// slots. See docs/variant.md — Test: ..._drops_zero_uv_and_out_of_range. + /// `variant_uv_slots` must drop `uv == 0` and `u_mask_shift >= 32` slots. #[test] fn variant_uv_slots_drops_zero_uv_and_out_of_range_shift_slots() { // Four slots: uv == 0, shift == 32 (the exact boundary), shift == 0x3F @@ -1352,8 +1332,7 @@ mod tests { ); } - /// THE happy path for the EXPLICIT-INPUT entry point: must derive the - /// planted `(Km, Kvu)`. See docs/variant.md — Test: ..._derives_the_.... + /// THE happy path for the EXPLICIT-INPUT entry point: must derive the planted `(Km, Kvu)`. #[test] fn media_key_variant_from_kp_derives_the_planted_media_key_and_volume_unique_key() { let p = plant_variant_mkb(); @@ -1384,8 +1363,8 @@ mod tests { assert_ne!(kvu, aes_g(&p.kp, &vid)); } - /// Each caller-supplied value (`c_block`, `uv`, `variants_uv`) wrong - /// must yield an error, never a key. See docs/variant.md — Test: ..._refuses_every_.... + /// Each caller-supplied value (`c_block`, `uv`, `variants_uv`) wrong must yield an error, + /// never a key. #[test] fn media_key_variant_from_kp_refuses_every_single_wrong_explicit_input() { let p = plant_variant_mkb(); @@ -1484,8 +1463,8 @@ mod tests { c_block1: [u8; 16], } - /// Build a two-slot variant MKB keyed by a DEVICE key at slot **1**, - /// behind a decoy at slot 0. See docs/variant.md — Test fixture: plant_walk_variant_mkb. + /// Build a two-slot variant MKB keyed by a DEVICE key at slot **1**, behind a decoy at slot + /// 0. fn plant_walk_variant_mkb() -> PlantedWalk { use crate::aacs::crypto::{aes_ecb_encrypt, aes_g}; @@ -1611,9 +1590,8 @@ mod tests { ); } - /// `walk_processing_key` must return the Kp/uv/cvalue/index of the - /// COVERING slot — slot **1**, not slot 0. See docs/variant.md — Test: - /// walk_processing_key_returns_the_covering_slots_key_cvalue_and_index. + /// `walk_processing_key` must return the Kp/uv/cvalue/index of the COVERING slot — slot + /// **1**, not slot 0. #[test] fn walk_processing_key_returns_the_covering_slots_key_cvalue_and_index() { let p = plant_walk_variant_mkb(); @@ -1732,9 +1710,8 @@ mod tests { ); } - /// Both halves of `classical_ok || variant_present` must hold: strip the - /// variant records from a fixture whose magic does NOT hold and the - /// walk must go quiet. See docs/variant.md — Test: ..._needs_either_.... + /// Both halves of `classical_ok || variant_present` must hold: strip the variant records + /// from a fixture whose magic does NOT hold and the walk must go quiet. #[test] fn walk_processing_key_needs_either_the_verify_magic_or_variant_records() { let p = plant_walk_variant_mkb(); @@ -1760,9 +1737,8 @@ mod tests { ); } - /// The OTHER half of `classical_ok || variant_present`: a classical MKB - /// whose cvalue opens the magic must match, computed exactly (`uv` - /// XORed into the LOW FOUR BYTES). See docs/variant.md — Test: ..._authenticates_.... + /// The OTHER half of `classical_ok || variant_present`: a classical MKB whose cvalue opens + /// the magic must match, computed exactly (`uv` XORed into the LOW FOUR BYTES). #[test] fn walk_processing_key_authenticates_a_classical_match_through_the_verify_magic() { use crate::aacs::crypto::aes_ecb_encrypt; diff --git a/src/bdnav/index.rs b/src/bdnav/index.rs index e6cc8416..0f7d8981 100644 --- a/src/bdnav/index.rs +++ b/src/bdnav/index.rs @@ -2,8 +2,6 @@ //! and the title table), per its documented binary layout. This is read as a //! documented binary format, never executed: every field is bounds-checked and //! any malformed input yields `None` (the nav resolver then abstains). -//! -//! See docs/index-bdmv.md for the full binary layout. /// One playback/title object in `index.bdmv`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/bdnav/mobj.rs b/src/bdnav/mobj.rs index aec895a2..bd42ff42 100644 --- a/src/bdnav/mobj.rs +++ b/src/bdnav/mobj.rs @@ -8,7 +8,6 @@ //! by `num_cmds` 12-byte navigation commands. // One decoded 12-byte navigation command. -// See docs/mobj.md — byte-level field layout (big-endian). #[derive(Debug, Clone, Copy)] pub(crate) struct Cmd { pub op_cnt: u8, diff --git a/src/bdnav/mod.rs b/src/bdnav/mod.rs index c9f94414..97240883 100644 --- a/src/bdnav/mod.rs +++ b/src/bdnav/mod.rs @@ -3,7 +3,6 @@ //! runs a faithful, bounded HDMV navigation VM to find the playlist the disc's //! own First-Play navigation plays as the feature. //! -//! See docs/bdnav.md for the no-special-casing rationale and the BD-J/abstain boundary. //! //! Contract: read-only, bounded, and never panics or hard-fails. @@ -15,9 +14,9 @@ pub(crate) mod vm; use crate::sector::SectorSource; use crate::udf::UdfFs; -// Resolve the playlist id First-Play navigation plays as the feature, among -// ids the caller marks as feature candidates (`is_feature_candidate`). -// Returns `None` for BD-J discs, malformed nav data, or non-convergence; see docs/bdnav.md. +// Resolve the playlist id First-Play navigation plays as the feature, among ids the caller +// marks as feature candidates (`is_feature_candidate`). Returns `None` for BD-J discs, +// malformed nav data, or non-convergence. pub(crate) fn resolve_feature( reader: &mut dyn SectorSource, udf: &UdfFs, diff --git a/src/bdnav/vm.rs b/src/bdnav/vm.rs index 5993928e..e1b67a90 100644 --- a/src/bdnav/vm.rs +++ b/src/bdnav/vm.rs @@ -6,8 +6,6 @@ //! First-Play is a BD-J title (the feature is chosen by a Java Xlet), or the //! program does not reach a `PlayPL` on a caller-approved feature candidate, it //! abstains (`None`) and selection falls back to the structural/heuristic order. -// -// See docs/vm.md — why power-on register fidelity matters for convergence. use super::index::{Index, PlaybackObj}; use super::mobj::MovieObject; diff --git a/src/clpi.rs b/src/clpi.rs index f0a7c049..ba05a708 100644 --- a/src/clpi.rs +++ b/src/clpi.rs @@ -71,9 +71,8 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> { }) } -// Parse the ProgramInfo section: per-stream (pid, coding_type, language, -// codec sub-fields). Returns Vec::new() on any structural mismatch. See -// docs/clpi.md — "ProgramInfo Section" for the full byte layout. +// Parse the ProgramInfo section: per-stream (pid, coding_type, language, codec sub-fields). +// Returns Vec::new() on any structural mismatch. fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> { use crate::consts::coding_type as c; let mut out = Vec::new(); @@ -200,9 +199,8 @@ mod tests { assert!(parse(&data).is_err()); } - // Added hardening tests, grounded in the BD-ROM CLPI spec byte layout. - // Build a ProgramInfo section; `streams` = Vec<(pid, sci_bytes)> — see - // docs/clpi.md "ProgramInfo Section" for the full byte layout. + // Added hardening tests, grounded in the BD-ROM CLPI spec byte layout. Build a ProgramInfo + // section; `streams` = Vec<(pid, sci_bytes)>. fn build_program_info(streams: &[(u16, Vec<u8>)]) -> Vec<u8> { let mut body = Vec::new(); body.push(0); // reserved (offset 4) @@ -388,9 +386,8 @@ mod tests { assert!(clip.streams.is_empty()); } - // Section-offset gates in `parse`: prog_info_start == 0 means "no - // ProgramInfo section" — offset-0 bytes must NOT be reinterpreted as a - // table. See docs/clpi.md "Test fixture notes" for the fixture design. + // Section-offset gates in `parse`: prog_info_start == 0 means "no ProgramInfo section" — + // offset-0 bytes must NOT be reinterpreted as a table. #[test] fn prog_info_start_zero_does_not_parse_header_as_program_info() { let mut data = build_clpi(1000, None); diff --git a/src/consts.rs b/src/consts.rs index ae21ff62..8ba87a89 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -54,8 +54,6 @@ pub const BD_SOURCE_PACKET_BYTES: usize = TS_PACKET_BYTES + BD_TIMESTAMP_PREFIX_ /// every consumer of either spec reads or writes this one byte. Each constant /// is `u8`: the spec defines an 8-bit field, so the code compares it directly /// against a buffer byte with no casts. -/// -/// See docs/consts.md for the ISO/BD-ROM table citations and user-private-range notes. pub mod coding_type { /// MPEG-2 video (ISO/IEC 13818-1 Table 2-34). pub const MPEG2_VIDEO: u8 = 0x02; diff --git a/src/css/keyless.rs b/src/css/keyless.rs index 29ae1e81..49ad926b 100644 --- a/src/css/keyless.rs +++ b/src/css/keyless.rs @@ -4,9 +4,6 @@ //! no player keys and no disc-key crack, using only known plaintext. //! Implemented from that public description; nothing here is copied or //! translated from any particular CSS software. -//! -//! See `docs/css-keyless.md` for the cipher structure this attacks and the -//! four steps of the attack itself. use super::lfsr::descramble_sector; use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5}; @@ -23,8 +20,7 @@ fn lfsr0_output_tap(x: u32) -> u32 { (((((((x >> 3) ^ x) >> 1) ^ x) >> 8) ^ x) >> 5) & 0xff } -// Recover the title key from cipher + known plaintext. `seed` is -// sector[0x54..0x59]. See docs/css-keyless.md — recover_title_key_from_plain. +// Recover the title key from cipher + known plaintext. `seed` is sector[0x54..0x59]. fn recover_title_key_from_plain( crypted: &[u8], decrypted: &[u8], @@ -223,9 +219,8 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> { result } -// Crib: the predicted 10-byte plaintext at byte 0x80, from the longest -// periodic run in the clear header. Also the decrypt path's cached-key -// oracle. See docs/css-keyless.md — attack_crib. +// Crib: the predicted 10-byte plaintext at byte 0x80, from the longest periodic run in the +// clear header. Also the decrypt path's cached-key oracle. pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> { if sector.len() < SECTOR_BYTES || sector[FLAG_BYTE] & 0x30 == 0 { return None; @@ -290,8 +285,7 @@ mod tests { use super::super::lfsr::scramble_sector; use super::*; - // Synthesize a scrambled sector for a title key/seed with `plain` at byte - // 0x80. See docs/css-keyless.md — synth_sector. + // Synthesize a scrambled sector for a title key/seed with `plain` at byte 0x80. fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) { let mut plaintext = vec![0u8; SECTOR_BYTES]; plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); @@ -307,8 +301,8 @@ mod tests { (plaintext, body) } - // Synthesize a sector whose cleartext ends in a periodic run continuing - // into the encrypted region. See docs/css-keyless.md — synth_periodic_sector. + // Synthesize a sector whose cleartext ends in a periodic run continuing into the encrypted + // region. fn synth_periodic_sector( title_key: &[u8; 5], seed: &[u8; 5], @@ -359,8 +353,8 @@ mod tests { /// A realistic MPEG-2 PES header start. const PES: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21]; - // MANDATORY round-trip (Task C.1): recovered key must descramble the - // body back to plaintext. See docs/css-keyless.md — recover_round_trips_known_keys. + // MANDATORY round-trip (Task C.1): recovered key must descramble the body back to + // plaintext. #[test] fn recover_round_trips_known_keys() { let cases: &[([u8; 5], [u8; 5])] = &[ @@ -399,9 +393,8 @@ mod tests { } } - // descramble_matches is the ONLY gate between the LFSR search and a key - // handed to the caller. See docs/css-keyless.md — - // descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with. + // descramble_matches is the ONLY gate between the LFSR search and a key handed to the + // caller. #[test] fn descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with() { let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; @@ -426,7 +419,6 @@ mod tests { } // The gate must verify against a COPY, never mutate the caller's sector. - // See docs/css-keyless.md — descramble_matches_does_not_disturb_the_caller_s_sector. #[test] fn descramble_matches_does_not_disturb_the_caller_s_sector() { let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; @@ -567,9 +559,8 @@ mod tests { } } - // ── entry-point guards on caller- and disc-supplied lengths: a sector - // buffer that ENDS inside the encrypted region must be refused, not - // sliced. See docs/css-keyless.md — recover_rejects_a_sector_that_ends_inside_the_encrypted_region. + // ── entry-point guards on caller- and disc-supplied lengths: a sector buffer that ENDS + // inside the encrypted region must be refused, not sliced. #[test] fn recover_rejects_a_sector_that_ends_inside_the_encrypted_region() { for len in [0x81usize, 0x85, 0x89] { @@ -582,9 +573,8 @@ mod tests { } } - // A buffer LONGER than one sector is still one sector: both entry points - // must recover from the first SECTOR_BYTES. See docs/css-keyless.md — - // a_buffer_longer_than_one_sector_still_yields_its_key. + // A buffer LONGER than one sector is still one sector: both entry points must recover from + // the first SECTOR_BYTES. #[test] fn a_buffer_longer_than_one_sector_still_yields_its_key() { let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; @@ -610,9 +600,8 @@ mod tests { assert!(crack_title_key(&padded).is_some()); } - // recover_title_key accepts and uses MORE than ten bytes of known - // plaintext (ten is a MINIMUM). See docs/css-keyless.md — - // recover_accepts_more_than_ten_bytes_of_known_plaintext. + // recover_title_key accepts and uses MORE than ten bytes of known plaintext (ten is a + // MINIMUM). #[test] fn recover_accepts_more_than_ten_bytes_of_known_plaintext() { let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; @@ -630,9 +619,8 @@ mod tests { ); } - // The scramble-flag gate on a sector whose BODY really is ciphertext and - // whose key IS recoverable. See docs/css-keyless.md — - // a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused. + // The scramble-flag gate on a sector whose BODY really is ciphertext and whose key IS + // recoverable. #[test] fn a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused() { let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; @@ -676,9 +664,8 @@ mod tests { ); } - // ── descramble_matches: the verification gate's own mechanics — the gate - // must verify against the sector's CIPHERTEXT regardless of the flag - // byte. See docs/css-keyless.md — descramble_matches_forces_the_scramble_flag_on_its_own_copy. + // ── descramble_matches: the verification gate's own mechanics — the gate must verify + // against the sector's CIPHERTEXT regardless of the flag byte. #[test] fn descramble_matches_forces_the_scramble_flag_on_its_own_copy() { let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; @@ -696,9 +683,7 @@ mod tests { assert!(!descramble_matches(§or, &wrong, &PES)); } - // The gate compares the WHOLE supplied plaintext, clamped to the - // encrypted region. See docs/css-keyless.md — - // descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector. + // The gate compares the WHOLE supplied plaintext, clamped to the encrypted region. #[test] fn descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector() { let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; @@ -740,8 +725,7 @@ mod tests { } // ── attack_crib: known-answer vectors. Sector whose clear header ends in - // a period-length run of run_len bytes before 0x80, rest 0xFF. See - // docs/css-keyless.md — sector_with_trailing_run. + // a period-length run of run_len bytes before 0x80, rest 0xFF. fn sector_with_trailing_run(period: usize, run_len: usize) -> Vec<u8> { assert!( run_len < ENCRYPTED_START, @@ -770,8 +754,7 @@ mod tests { } // KNOWN ANSWER: the crib is the period-5 run continued forward, the same - // ten bytes for every run length. See docs/css-keyless.md — - // attack_crib_predicts_the_periodic_run_continuing_past_0x80. + // ten bytes for every run length. #[test] fn attack_crib_predicts_the_periodic_run_continuing_past_0x80() { for &run_len in &[11usize, 12, 13, 14, 15, 16, 20, 31] { @@ -809,9 +792,8 @@ mod tests { } } - // A run of exactly ONE cycle is not enough to predict forward; attack_crib - // requires two full cycles. See docs/css-keyless.md — - // attack_crib_refuses_a_run_shorter_than_two_cycles. + // A run of exactly ONE cycle is not enough to predict forward; attack_crib requires two + // full cycles. #[test] fn attack_crib_refuses_a_run_shorter_than_two_cycles() { // period 8, run of 9 bytes: best_plen = 8, 8 / 8 == 1 cycle. @@ -840,9 +822,8 @@ mod tests { assert_eq!(crack_title_key(§or), None); } - // attack_crib indexes sector[0x7f - j] unbounded, so its own length guard - // is the only thing stopping an out-of-bounds read. See docs/css-keyless.md - // — attack_crib_refuses_a_buffer_shorter_than_a_sector. + // attack_crib indexes sector[0x7f - j] unbounded, so its own length guard is the only thing + // stopping an out-of-bounds read. #[test] fn attack_crib_refuses_a_buffer_shorter_than_a_sector() { for len in [0x15usize, 0x40, 0x7F, SECTOR_BYTES - 1] { @@ -856,9 +837,8 @@ mod tests { } } - // A header periodic ALL THE WAY to offset 0 must not walk the backward - // scan off the front of the sector. See docs/css-keyless.md — - // attack_crib_survives_a_header_that_is_periodic_to_offset_zero. + // A header periodic ALL THE WAY to offset 0 must not walk the backward scan off the front + // of the sector. #[test] fn attack_crib_survives_a_header_that_is_periodic_to_offset_zero() { let mut sector = vec![0u8; SECTOR_BYTES]; @@ -890,8 +870,7 @@ mod tests { ); } - // The crib is read from the CLEAR header only, never the encrypted - // region. See docs/css-keyless.md — attack_crib_is_independent_of_the_encrypted_region. + // The crib is read from the CLEAR header only, never the encrypted region. #[test] fn attack_crib_is_independent_of_the_encrypted_region() { let base = sector_with_trailing_run(5, 11); @@ -909,9 +888,8 @@ mod tests { } } - // ── recover_title_key_from_plain: input-length guard is the only thing - // standing between a short slice and an index-out-of-bounds PANIC. See - // docs/css-keyless.md — recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input. + // ── recover_title_key_from_plain: input-length guard is the only thing standing between a + // short slice and an index-out-of-bounds PANIC. #[test] fn recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input() { let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; @@ -944,9 +922,7 @@ mod tests { ); } - // The seed XOR-back turns the recovered LFSR key into the TITLE key: - // key ^= sector_seed. See docs/css-keyless.md — - // recover_title_key_from_plain_xors_the_sector_seed_back_out. + // The seed XOR-back turns the recovered LFSR key into the TITLE key: key ^= sector_seed. #[test] fn recover_title_key_from_plain_xors_the_sector_seed_back_out() { let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; diff --git a/src/css/lfsr.rs b/src/css/lfsr.rs index 0bb629c1..08236753 100644 --- a/src/css/lfsr.rs +++ b/src/css/lfsr.rs @@ -5,8 +5,8 @@ //! - **LFSR0** — 24-bit register seeded from `key[2..5] XOR seed[2..5]`, //! stepped through a feedback polynomial and `TAB4`. //! -//! Output byte = sum-with-carry of both registers; body byte recovered as -//! `plain = TAB1[cipher] ^ keystream`. See docs/lfsr.md for more detail. +//! Output byte = sum-with-carry of both registers; body byte recovered as `plain = TAB1[cipher] +//! ^ keystream`. use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5}; @@ -18,8 +18,8 @@ use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5}; /// `0x14` (bits 4-5) is CLEARED after unscrambling, so a descrambled sector /// reads as `sector[0x14] & 0x30 == 0`. /// -/// No-op (returns without modifying `sector`) if `sector.len() < 2048` or -/// the scramble flag bits are already zero. See docs/lfsr.md for rationale. +/// No-op (returns without modifying `sector`) if `sector.len() < 2048` or the scramble flag +/// bits are already zero. pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) { debug_assert!( sector.len() >= 2048, @@ -75,8 +75,7 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) { sector[0x14] &= 0xCF; } -// Exact inverse of descramble_sector, for test use only (builds known -// ciphertext). See docs/lfsr.md — scramble_sector. +// Exact inverse of descramble_sector, for test use only (builds known ciphertext). #[cfg(test)] pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) { if sector.len() < 2048 { @@ -139,9 +138,8 @@ mod tests { assert_eq!(sector, original); } - // Regression vector pinning the implementation's deterministic output. - // key = 42 13 37 BE EF, seed = DE AD BE EF 42, body = 0xAA. - // See docs/lfsr.md — reference vector test. + // Regression vector pinning the implementation's deterministic output. key = 42 13 37 BE + // EF, seed = DE AD BE EF 42, body = 0xAA. #[test] fn descramble_produces_the_reference_css_vector() { let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; @@ -194,8 +192,8 @@ mod tests { assert_eq!(sector[0x14] & 0x30, 0x00); } - // Not a plain XOR involution (TAB1 applies to ciphertext); scramble_sector - // is the true inverse. See docs/lfsr.md — inverts-scramble test. + // Not a plain XOR involution (TAB1 applies to ciphertext); scramble_sector is the true + // inverse. #[test] fn css_descramble_inverts_scramble_over_body() { let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; @@ -272,8 +270,8 @@ mod tests { } } - // Scramble-flag detection (byte 0x14, bits 4-5): only bits 4-5 are the - // flag (mask 0x30); 0x40/0x80 must read UNSCRAMBLED. See docs/lfsr.md. + // Scramble-flag detection (byte 0x14, bits 4-5): only bits 4-5 are the flag (mask 0x30); + // 0x40/0x80 must read UNSCRAMBLED. #[test] fn descramble_treats_high_bits_of_0x14_as_clear() { let key = [0x01, 0x02, 0x03, 0x04, 0x05]; @@ -290,8 +288,8 @@ mod tests { } } - // Each scramble bit (4 and 5) independently marks a sector encrypted: - // 0x10 and 0x20 must both trigger descrambling. See docs/lfsr.md. + // Each scramble bit (4 and 5) independently marks a sector encrypted: 0x10 and 0x20 must + // both trigger descrambling. #[test] fn descramble_triggers_on_either_flag_bit() { let key = [0x01, 0x02, 0x03, 0x04, 0x05]; @@ -309,8 +307,8 @@ mod tests { } } - // Only the two scramble bits are cleared (`& 0xCF`); bits 6-7 of byte - // 0x14 survive: 0xF0 becomes 0xC0, NOT 0x00. See docs/lfsr.md. + // Only the two scramble bits are cleared (`& 0xCF`); bits 6-7 of byte 0x14 survive: 0xF0 + // becomes 0xC0, NOT 0x00. #[test] fn descramble_clear_preserves_high_bits_of_0x14() { let key = [0x01, 0x02, 0x03, 0x04, 0x05]; @@ -326,8 +324,8 @@ mod tests { // ── header / body boundary (encrypted region is 0x80..0x800) ─────────── - // Encrypted region is exactly 0x80..0x800; header bytes 0x00..0x80 - // (incl. the seed at 0x54..0x59) must stay untouched. See docs/lfsr.md. + // Encrypted region is exactly 0x80..0x800; header bytes 0x00..0x80 (incl. the seed at + // 0x54..0x59) must stay untouched. #[test] fn descramble_leaves_header_and_seed_intact() { let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; @@ -349,8 +347,8 @@ mod tests { assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive"); } - // The descrambler must touch the WHOLE body, including the final byte - // (index 2047), guarding the `.take(2048)` bound. See docs/lfsr.md. + // The descrambler must touch the WHOLE body, including the final byte (index 2047), + // guarding the `.take(2048)` bound. #[test] fn descramble_covers_final_body_byte() { let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; @@ -367,8 +365,8 @@ mod tests { ); } - // The length guard is a FLOOR, not a ceiling: descramble_sector processes - // the first sector of any over-long buffer. See docs/lfsr.md. + // The length guard is a FLOOR, not a ceiling: descramble_sector processes the first sector + // of any over-long buffer. #[test] fn descramble_processes_the_first_sector_of_an_over_long_buffer() { let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; @@ -405,8 +403,8 @@ mod tests { ); } - // Descramble is keyed by `title_key XOR seed`: two different title keys - // must produce two different bodies. See docs/lfsr.md. + // Descramble is keyed by `title_key XOR seed`: two different title keys must produce two + // different bodies. #[test] fn descramble_output_depends_on_title_key() { let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42]; @@ -426,8 +424,8 @@ mod tests { ); } - // Descramble is keyed by the sector seed too: same title key, different - // seed -> different body. See docs/lfsr.md. + // Descramble is keyed by the sector seed too: same title key, different seed -> different + // body. #[test] fn descramble_output_depends_on_seed() { let key = [0x01, 0x02, 0x03, 0x04, 0x05]; diff --git a/src/css/mod.rs b/src/css/mod.rs index 4cd0008e..6ab0ee96 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -21,9 +21,8 @@ pub(crate) mod tables; use crate::disc::Extent; use crate::sector::SectorSource; -// Consecutive CSS-locked reads before the crack scan early-bails, instead of -// grinding the full 50_000-sector budget. Resets to 0 on any readable batch. -// See docs/css-mod.md — CSS_LOCKED_BAIL. +// Consecutive CSS-locked reads before the crack scan early-bails, instead of grinding the full +// 50_000-sector budget. Resets to 0 on any readable batch. const CSS_LOCKED_BAIL: u32 = 64; /// CSS decryption state for a DVD title. @@ -60,9 +59,8 @@ impl std::fmt::Debug for CssState { /// that yields a key — no player keys, no disc-key crack. Works on a live /// drive and on disc images alike. /// -/// This convenience form runs to completion (no cancellation); callers -/// needing a cancel token, or the three-way [`CrackOutcome`], use -/// [`crack_key_outcome`]. See docs/css-mod.md — `crack_key`. +/// This convenience form runs to completion (no cancellation); callers needing a cancel token, +/// or the three-way [`CrackOutcome`], use [`crack_key_outcome`]. pub fn crack_key( reader: &mut dyn SectorSource, extents: &[Extent], @@ -71,8 +69,8 @@ pub fn crack_key( crack_key_scan(reader, extents, batch_sectors, None).into_state() } -/// Outcome of a CSS crack scan: distinguishes the THREE cases a bare -/// `Option<CssState>` conflated (see docs/css-mod.md — `CrackOutcome`). +/// Outcome of a CSS crack scan: distinguishes the THREE cases a bare `Option<CssState>` +/// conflated. /// /// - [`CrackOutcome::Cracked`] — a scrambled sector yielded a title key. /// - [`CrackOutcome::Unencrypted`] — no scrambled sector was seen; genuinely @@ -120,9 +118,9 @@ pub fn crack_key_outcome( crack_key_scan(reader, extents, batch_sectors, halt) } -// The SINGLE place every DVD read path obtains a title key when the caller -// supplied none. A scrambled-but-uncrackable title is a hard, skippable -// per-title CssKeyMissing (another VTS may still crack). See docs/css-mod.md. +// The SINGLE place every DVD read path obtains a title key when the caller supplied none. A +// scrambled-but-uncrackable title is a hard, skippable per-title CssKeyMissing (another VTS may +// still crack). pub(crate) fn resolve_dvd_title_key( reader: &mut dyn SectorSource, extents: &[Extent], @@ -297,12 +295,10 @@ fn crack_key_scan( /// Descramble a single CSS-encrypted sector in place. /// -/// A no-op unless the sector is a scrambled MPEG-2 PS PACK: the pack start -/// code is checked, not just the byte 0x14 flag bits, since 0x14 alone is -/// unreliable outside a pack (see [`is_scrambled_pack`]). Making the guard -/// part of the function, rather than something each caller must remember, is -/// what keeps the safe path the easy one — see docs/css-mod.md for the -/// measured `VIDEO_TS.IFO` corruption this prevents. +/// A no-op unless the sector is a scrambled MPEG-2 PS PACK: the pack start code is checked, not +/// just the byte 0x14 flag bits, since 0x14 alone is unreliable outside a pack (see +/// [`is_scrambled_pack`]). Making the guard part of the function, rather than something each +/// caller must remember, is what keeps the safe path the easy one. pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { if !is_scrambled_pack(sector) { return; @@ -310,11 +306,9 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { lfsr::descramble_sector(&state.title_key, sector); } -/// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB -/// region boundary. `title_key` is a CACHE of the last crack: validated -/// against the clear-header crib on every scrambled sector and re-cracked on -/// a miss. A crib-less sector rides the cached key. See docs/css-mod.md — -/// `descramble_region` for the full rationale. +/// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB region boundary. +/// `title_key` is a CACHE of the last crack: validated against the clear-header crib on every +/// scrambled sector and re-cracked on a miss. A crib-less sector rides the cached key. /// /// # Errors /// @@ -362,9 +356,8 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::erro /// Whether bits 4-5 of the sub-header byte 0x14 are set. NOTHING MORE. /// -/// This is deliberately NOT called `is_scrambled`: byte 0x14 is only -/// meaningful inside an MPEG-2 PS pack, and treating the flag alone as proof -/// of scrambling corrupted a real disc (see docs/css-mod.md). +/// This is deliberately NOT called `is_scrambled`: byte 0x14 is only meaningful inside an +/// MPEG-2 PS pack, and treating the flag alone as proof of scrambling corrupted a real disc. /// /// **Callers want [`is_scrambled_pack`].** It also requires the pack start /// code, which every genuinely scrambled VOB sector carries and no IFO sector @@ -383,11 +376,10 @@ pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; /// crack scan's `saw_scrambled` flag and [`descramble_sector`]/[`descramble_region`] share. /// /// Requires BOTH the MPEG-PS pack-start code AND the 0x14 scramble bits -/// ([`has_scramble_flag_bits`] alone is not enough), and excludes structural/nav -/// `stream_id`s at 0x11 (`0xBB`/`0xBE`/`0xBF`) that CSS never scrambles so a decrypted -/// HD-DVD's RDI nav packs can't falsely trip it. 0x11 holds the `stream_id` only for -/// zero-stuffing headers (always true for DVD-Video VOB packs); a stuffed pack reads -/// 0xFF there, harmless. See docs/css-mod.md — `is_scrambled_pack`. +/// ([`has_scramble_flag_bits`] alone is not enough), and excludes structural/nav `stream_id`s +/// at 0x11 (`0xBB`/`0xBE`/`0xBF`) that CSS never scrambles so a decrypted HD-DVD's RDI nav +/// packs can't falsely trip it. 0x11 holds the `stream_id` only for zero-stuffing headers +/// (always true for DVD-Video VOB packs); a stuffed pack reads 0xFF there, harmless. pub fn is_scrambled_pack(sector: &[u8]) -> bool { use crate::consts::pes_stream_id::{PADDING_STREAM, PRIVATE_STREAM_2, SYSTEM_HEADER}; sector.len() >= 2048 @@ -404,9 +396,8 @@ mod tests { use super::*; use crate::error::{Error, Result}; - // A crib mismatch whose re-crack fails keeps the CACHED key and - // descrambles with it — it does NOT fail the rip (a crib false positive, - // not a stale key). See docs/css-mod.md — this test's name. + // A crib mismatch whose re-crack fails keeps the CACHED key and descrambles with it — it + // does NOT fail the rip (a crib false positive, not a stale key). #[test] fn a_crib_false_positive_keeps_the_cached_key_rather_than_failing() { // Header periodic enough to yield a crib, body random enough that no @@ -487,8 +478,7 @@ mod tests { assert!(!has_scramble_flag_bits(&[0xFFu8; 2047])); // one byte short of a sector } - // Keys on bits 4-5 of byte 0x14 only: 0x10/0x20/0x30 is scrambled, - // 0x00/0x40/0x80 is clear. See docs/css-mod.md — this test's name. + // Keys on bits 4-5 of byte 0x14 only: 0x10/0x20/0x30 is scrambled, 0x00/0x40/0x80 is clear. #[test] fn has_scramble_flag_bits_uses_bits_4_5_only() { let mut s = vec![0u8; 2048]; @@ -552,9 +542,8 @@ mod tests { ); } - // Detection fix: `is_scrambled_pack` must EXCLUDE structural/nav - // stream_ids (0xBB/0xBE/0xBF) at 0x11, the exact decrypted-HD-DVD defect - // (an `.evo` RDI pack is 0xBF). See docs/css-mod.md — this test's name. + // Detection fix: `is_scrambled_pack` must EXCLUDE structural/nav stream_ids + // (0xBB/0xBE/0xBF) at 0x11, the exact decrypted-HD-DVD defect (an `.evo` RDI pack is 0xBF). #[test] fn is_scrambled_pack_excludes_nav_and_structural_stream_ids() { use crate::consts::pes_stream_id::{ @@ -603,9 +592,8 @@ mod tests { /// `recovery: true` source may return. `Some(0)` must not spin /// the scan. short_read: Option<usize>, - /// PES `stream_id` at offset 0x11 of each uniform-fill sector; `0xBF` - /// models an HD-DVD `.evo` RDI nav pack (pack-start + 0x14 bits, no - /// CSS). See docs/css-mod.md. + /// PES `stream_id` at offset 0x11 of each uniform-fill sector; `0xBF` models an HD-DVD + /// `.evo` RDI nav pack (pack-start + 0x14 bits, no CSS). stream_id: u8, } @@ -751,9 +739,9 @@ mod tests { ); } - // The 50_000-sector budget must hold even when a source always returns - // Ok(0): `tried` is charged in the `usable == 0` arm so a short-answering - // source can't turn off the anti-grind bound. See docs/css-mod.md. + // The 50_000-sector budget must hold even when a source always returns Ok(0): `tried` is + // charged in the `usable == 0` arm so a short-answering source can't turn off the + // anti-grind bound. #[test] fn a_source_that_returns_zero_sectors_still_obeys_the_scan_budget() { const MAX_TRIES: usize = 50_000; @@ -833,9 +821,8 @@ mod tests { assert!(crack_key(&mut MockSource::new(0x30), &extents, 1).is_none()); } - // The PUBLIC per-sector entry point must also refuse a non-pack sector - // (a real VIDEO_TS.IFO sector with 0x14 bits set but no pack-start), - // matching the region-level guard. See docs/css-mod.md. + // The PUBLIC per-sector entry point must also refuse a non-pack sector (a real VIDEO_TS.IFO + // sector with 0x14 bits set but no pack-start), matching the region-level guard. #[test] fn descramble_sector_refuses_a_non_pack_sector() { let mut ifo_like = vec![0u8; 2048]; @@ -953,9 +940,8 @@ mod tests { ); } - // crack_key (the Option wrapper) collapses ScrambledUncracked and - // Unencrypted alike to None; callers needing to tell them apart must use - // crack_key_outcome. See docs/css-mod.md. + // crack_key (the Option wrapper) collapses ScrambledUncracked and Unencrypted alike to + // None; callers needing to tell them apart must use crack_key_outcome. #[test] fn crack_key_all_locked_collapses_to_none() { let mut src = MockSource::new(0x30); @@ -1077,8 +1063,8 @@ mod tests { // ── Scan-level Cracked branch + per-VTS re-crack success (audit §2 / §5 #8) ─ - // SCAN-LEVEL CRACKED: exercises the Cracked branch and crack_span - // recording end-to-end, previously untested. See docs/css-mod.md. + // SCAN-LEVEL CRACKED: exercises the Cracked branch and crack_span recording end-to-end, + // previously untested. #[test] fn crack_outcome_reaches_cracked_with_span() { let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; @@ -1114,9 +1100,8 @@ mod tests { ); } - // `is_scrambled_uncracked` must be FALSE for Cracked/Unencrypted too, not - // just TRUE for ScrambledUncracked, else clear/cracked discs alike get - // routed to a hard error. See docs/css-mod.md. + // `is_scrambled_uncracked` must be FALSE for Cracked/Unencrypted too, not just TRUE for + // ScrambledUncracked, else clear/cracked discs alike get routed to a hard error. #[test] fn is_scrambled_uncracked_is_true_for_that_case_and_false_for_the_other_two() { let extents = [Extent { @@ -1235,9 +1220,9 @@ mod tests { ); } - // The decrypted-HD-DVD regression end to end: its 0xBF RDI nav packs must - // not flip `saw_scrambled` (no CSS exists on HD-DVD), else the scan - // hard-fails a good disc with CssKeyMissing (E7023). See docs/css-mod.md. + // The decrypted-HD-DVD regression end to end: its 0xBF RDI nav packs must not flip + // `saw_scrambled` (no CSS exists on HD-DVD), else the scan hard-fails a good disc with + // CssKeyMissing (E7023). #[test] fn resolve_dvd_title_key_decrypted_hddvd_rdi_packs_scan_clean_no_e7023() { let mut src = MockSource::new(0x30); // 0x14 bits set… @@ -1387,9 +1372,8 @@ mod tests { ); } - // CSS_ERROR WIRING: an all-locked synthetic ISO across MULTIPLE extents - // must produce ScrambledUncracked, the signal disc/mod.rs converts into - // css_error = CssKeyMissing. See docs/css-mod.md. + // CSS_ERROR WIRING: an all-locked synthetic ISO across MULTIPLE extents must produce + // ScrambledUncracked, the signal disc/mod.rs converts into css_error = CssKeyMissing. #[test] fn all_locked_synthetic_iso_yields_css_key_missing_signal() { let mut src = MockSource::new(0x30); @@ -1417,9 +1401,8 @@ mod tests { assert!(crack_key(&mut src2, &extents, 16).is_none()); } - // PER-VTS RE-CRACK SUCCESS: a re-crack over a DIFFERENT VTS's extents - // finds that VTS's own key, proving it is genuinely re-derived, not - // reused. See docs/css-mod.md. + // PER-VTS RE-CRACK SUCCESS: a re-crack over a DIFFERENT VTS's extents finds that VTS's own + // key, proving it is genuinely re-derived, not reused. #[test] fn recrack_succeeds_on_other_vts_extents() { let title_key = [0xFE, 0xDC, 0xBA, 0x98, 0x76]; diff --git a/src/css/tables.rs b/src/css/tables.rs index def5f492..d13b1e04 100644 --- a/src/css/tables.rs +++ b/src/css/tables.rs @@ -149,9 +149,8 @@ mod tests { } } - // TAB1 is a bijection on 0..256; CSS uses it as an invertible output - // permutation in css_DecryptKey. See docs/tables.md#tab1_is_a_permutation - // for the invertibility rationale and mutation-testing notes. + // TAB1 is a bijection on 0..256; CSS uses it as an invertible output permutation in + // css_DecryptKey. #[test] fn tab1_is_a_permutation() { let mut seen = [false; 256]; @@ -164,18 +163,16 @@ mod tests { } } - // TAB1's fixed structural anchors: TAB1[0x00] == 0x33 and TAB1[0x33] == - // 0x00, the canonical CSS-spec landmarks pinning the table's - // orientation. See docs/tables.md#tab1_known_spec_anchors for grounding. + // TAB1's fixed structural anchors: TAB1[0x00] == 0x33 and TAB1[0x33] == 0x00, the canonical + // CSS-spec landmarks pinning the table's orientation. #[test] fn tab1_known_spec_anchors() { assert_eq!(TAB1[0x00], 0x33, "TAB1[0] is the published 0x33"); assert_eq!(TAB1[0x33], 0x00, "TAB1[0x33] is the published 0x00"); } - // TAB2 is a permutation of 0..256 (LFSR1 high-byte feedback - // substitution); a non-bijective table would bias the LFSR1 keystream. - // See docs/tables.md#tab2_is_a_permutation for mutation notes. + // TAB2 is a permutation of 0..256 (LFSR1 high-byte feedback substitution); a non-bijective + // table would bias the LFSR1 keystream. #[test] fn tab2_is_a_permutation() { let mut seen = [false; 256]; @@ -188,9 +185,8 @@ mod tests { } } - // TAB3 is CSS's LFSR1 low-word table: BASE = [0x00,0x24,0x49,0x6d,0x92, - // 0xb6,0xdb,0xff] repeated 64x (TAB3[i] == BASE[i & 7]); only the low 3 - // bits of the 9-bit index matter. See docs/tables.md#tab3_matches_lfsr1_generating_formula. + // TAB3 is CSS's LFSR1 low-word table: BASE = [0x00,0x24,0x49,0x6d,0x92, 0xb6,0xdb,0xff] + // repeated 64x (TAB3[i] == BASE[i & 7]); only the low 3 bits of the 9-bit index matter. #[test] fn tab3_matches_lfsr1_generating_formula() { const BASE: [u8; 8] = [0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff]; @@ -204,9 +200,8 @@ mod tests { } } - // TAB4 is the exact bit-reversal of each byte (CSS permutes LFSR0 bytes - // with it on seed/output), hence also an involution: TAB4[TAB4[b]] == b. - // See docs/tables.md#tab4_bit_reversal for grounding/mutation notes. + // TAB4 is the exact bit-reversal of each byte (CSS permutes LFSR0 bytes with it on + // seed/output), hence also an involution: TAB4[TAB4[b]] == b. #[test] fn tab4_is_exact_bit_reversal_and_involution() { for b in 0u16..256 { @@ -229,9 +224,9 @@ mod tests { assert_eq!(TAB4[0xFF], 0xFF); } - // TAB4 is a permutation (bit-reversal is bijective); distinct from the - // reversal test since "reversal except swapped/duplicated entries" cases - // would pass one test but fail the other. See docs/tables.md#tab4_is_a_permutation. + // TAB4 is a permutation (bit-reversal is bijective); distinct from the reversal test since + // "reversal except swapped/duplicated entries" cases would pass one test but fail the + // other. #[test] fn tab4_is_a_permutation() { let mut seen = [false; 256]; @@ -241,9 +236,8 @@ mod tests { } } - // TAB5 is a permutation (complement of a bijection) with orientation - // anchors TAB5[0x00]==0xFF and TAB5[0xFF]==0x00, independent of the - // complement-loop test. See docs/tables.md#tab5_permutation_anchors. + // TAB5 is a permutation (complement of a bijection) with orientation anchors + // TAB5[0x00]==0xFF and TAB5[0xFF]==0x00, independent of the complement-loop test. #[test] fn tab5_is_permutation_with_anchors() { let mut seen = [false; 256]; diff --git a/src/decrypt.rs b/src/decrypt.rs index b04cb44f..79154967 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -2,11 +2,10 @@ //! from disc scanning; handles AACS 1.0/2.0 and CSS transparently, and //! the caller never sees encrypted data unless explicitly bypassed. //! -//! AACS aligned units decrypt independently, so buffers of at least -//! [`PARALLEL_MIN_UNITS`] units parallelize across a rayon pool; smaller -//! buffers use the serial path. Thread count resolves from -//! [`set_decrypt_threads`], else `FREEMKV_THREADS`, else all cores -//! (capped at [`MAX_THREADS`]). See docs/decrypt.md for detail. +//! AACS aligned units decrypt independently, so buffers of at least [`PARALLEL_MIN_UNITS`] +//! units parallelize across a rayon pool; smaller buffers use the serial path. Thread count +//! resolves from [`set_decrypt_threads`], else `FREEMKV_THREADS`, else all cores (capped at +//! [`MAX_THREADS`]). use crate::aacs; use crate::css; @@ -28,9 +27,8 @@ pub const MAX_THREADS: usize = 64; /// order. static DECRYPT_THREADS: AtomicUsize = AtomicUsize::new(0); -// Current rayon pool. `set_decrypt_threads` swaps it without leaking the -// old one; in-flight calls hold their own `Arc` via `decrypt_pool` and -// finish on it. See docs/decrypt.md — DECRYPT_POOL. +// Current rayon pool. `set_decrypt_threads` swaps it without leaking the old one; in-flight +// calls hold their own `Arc` via `decrypt_pool` and finish on it. static DECRYPT_POOL: RwLock<Option<Arc<rayon::ThreadPool>>> = RwLock::new(None); /// Configure how many threads to use for AACS unit decryption. A value @@ -51,9 +49,9 @@ pub fn set_decrypt_threads(n: usize) { *guard = None; } -// Get (or lazily build) the pool; `Arc` so in-flight work survives a -// concurrent `set_decrypt_threads` swap. `None` if unbuildable (e.g. OS -// thread limit) — caller falls back to serial. See docs/decrypt.md. +// Get (or lazily build) the pool; `Arc` so in-flight work survives a concurrent +// `set_decrypt_threads` swap. `None` if unbuildable (e.g. OS thread limit) — caller falls back +// to serial. fn decrypt_pool() -> Option<Arc<rayon::ThreadPool>> { // Fast path: pool already built. A poisoned read lock still yields a // usable guard (the pool Arc is immutable once stored). @@ -151,9 +149,8 @@ pub enum Phase { Odd, } -// Does this unit belong to the phase we hold the key for? `Phase::All` -// means the whole range is ours; a wrong index gets the wrong half. -// See docs/decrypt.md — unit_is_our_phase. +// Does this unit belong to the phase we hold the key for? `Phase::All` means the whole range is +// ours; a wrong index gets the wrong half. fn unit_is_our_phase(unit_lba: u32, range_start: u32, unit_sectors: u32, phase: Phase) -> bool { let want_odd = match phase { Phase::All => return true, @@ -167,11 +164,10 @@ fn unit_is_our_phase(unit_lba: u32, range_start: u32, unit_sectors: u32, phase: (unit_ix % 2 == 1) == want_odd } -/// Proactive AACS key-selection map: which held unit key decrypts each LBA of -/// a title's encrypted content, decided ONCE before mux from the disc's -/// CPS-unit (and, later, FMTS segment) structure — never by -/// trial-decrypt-and-check per unit at mux time. Ends the mux "key-server -/// storm" caused by that per-unit re-derivation. See docs/decrypt.md. +/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a title's +/// encrypted content, decided ONCE before mux from the disc's CPS-unit (and, later, FMTS +/// segment) structure — never by trial-decrypt-and-check per unit at mux time. Ends the mux +/// "key-server storm" caused by that per-unit re-derivation. /// /// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` /// pool, sorted and disjoint. An LBA in no range (incl. clear nav/filesystem @@ -252,14 +248,13 @@ impl AacsKeyMap { &self.key_indices } - /// Build the FMTS **read plan**: the title's aligned units filtered down to - /// only the units this rip must actually read — every default / CPS unit, - /// plus, inside each forensic segment, ONLY our-phase ([`Phase::Even`] / - /// [`Phase::Odd`]) units; alternate-phase units belong to a different device - /// group's variant and are omitted entirely. `extents` are the title's clip - /// extents; `unit_sectors` is the AACS aligned-unit size (3 sectors). A map - /// with no forensic range returns `extents` unchanged, and a kept unit is - /// always one `decrypt_sectors_mapped` would open. See docs/decrypt.md. + /// Build the FMTS **read plan**: the title's aligned units filtered down to only the units + /// this rip must actually read — every default / CPS unit, plus, inside each forensic + /// segment, ONLY our-phase ([`Phase::Even`] / [`Phase::Odd`]) units; alternate-phase units + /// belong to a different device group's variant and are omitted entirely. `extents` are the + /// title's clip extents; `unit_sectors` is the AACS aligned-unit size (3 sectors). A map + /// with no forensic range returns `extents` unchanged, and a kept unit is always one + /// `decrypt_sectors_mapped` would open. pub fn read_plan( &self, extents: &[crate::disc::Extent], @@ -380,9 +375,8 @@ fn lba_in_content_ranges(lba: u32, ranges: &[(u32, u32)]) -> bool { } } -// AACS scheme step: apply `map`'s per-unit keys to `buf`, in-place — no key -// trial, no `is_clean` verdict; the refusal decision belongs to -// `decrypt_span`. See docs/decrypt.md — apply_aacs_map. +// AACS scheme step: apply `map`'s per-unit keys to `buf`, in-place — no key trial, no +// `is_clean` verdict; the refusal decision belongs to `decrypt_span`. fn apply_aacs_map( buf: &mut [u8], keys: &DecryptKeys, @@ -495,12 +489,11 @@ fn apply_aacs_map( /// Decrypt a buffer of sectors in-place — the CSS / clear path only. /// -/// For CSS: descrambles per 2048-byte sector, self-cracking the title key -/// from the data. For `None`: a no-op. For AACS: **always** returns -/// `Err(DecryptFailed)` — AACS decrypts exclusively through the resolved key -/// map (`decrypt_sectors_mapped`); reaching this arm with AACS keys means a -/// reader was built without installing its map (a bug). `unit_key_idx` is a -/// legacy parameter, ignored. See docs/decrypt.md for full detail. +/// For CSS: descrambles per 2048-byte sector, self-cracking the title key from the data. For +/// `None`: a no-op. For AACS: **always** returns `Err(DecryptFailed)` — AACS decrypts +/// exclusively through the resolved key map (`decrypt_sectors_mapped`); reaching this arm with +/// AACS keys means a reader was built without installing its map (a bug). `unit_key_idx` is a +/// legacy parameter, ignored. pub fn decrypt_sectors( buf: &mut [u8], keys: &mut DecryptKeys, @@ -529,9 +522,8 @@ pub fn decrypt_sectors_in_content( decrypt_span(buf, keys, base_lba, None, Some((base_lba, content_ranges))) } -// THE decrypt orchestrator: every path into this crate's decryption goes -// through here. Resolve a key for the span, apply it, refuse if none can be -// proven — see docs/decrypt.md — decrypt_span for why the split matters. +// THE decrypt orchestrator: every path into this crate's decryption goes through here. Resolve +// a key for the span, apply it, refuse if none can be proven. fn decrypt_span( buf: &mut [u8], keys: &mut DecryptKeys, @@ -603,8 +595,8 @@ mod tests { // ── `decrypt_sectors_in_content` (now a legacy alias of `decrypt_sectors`) ── - // `DecryptKeys::None` is a no-op; a forensic read plan is NOT the extents - // it was given — the mux's provenance guard's precondition. docs/decrypt.md. + // `DecryptKeys::None` is a no-op; a forensic read plan is NOT the extents it was given — + // the mux's provenance guard's precondition. #[test] fn a_forensic_read_plan_drops_units_the_full_extents_include() { let full = vec![crate::disc::Extent { @@ -648,8 +640,8 @@ mod tests { assert_eq!(buf, original); } - // CSS ignores the content gate (it lives in the AACS arm) and always - // reports `0` — the read stays scheme-agnostic. See docs/decrypt.md. + // CSS ignores the content gate (it lives in the AACS arm) and always reports `0` — the read + // stays scheme-agnostic. #[test] fn content_gate_css_keys_is_noop() { let mut keys = DecryptKeys::Css { title_key: [0; 5] }; @@ -661,9 +653,8 @@ mod tests { ); } - // `decrypt_sectors_in_content` is the live read path for every mapped rip - // and must actually DECRYPT — the `_is_noop` tests above only pin `0`, - // which an `Ok(0)` stub also returns. See docs/decrypt.md. + // `decrypt_sectors_in_content` is the live read path for every mapped rip and must actually + // DECRYPT — the `_is_noop` tests above only pin `0`, which an `Ok(0)` stub also returns. #[test] fn content_gate_css_actually_descrambles_the_buffer() { const RUN_START: usize = 0x59; @@ -708,9 +699,9 @@ mod tests { ); } - // The AACS arm of the same entry point must fail LOUD: reaching it means - // a reader was built without installing its key map, and must not be - // softened into a success with a zero count. See docs/decrypt.md. + // The AACS arm of the same entry point must fail LOUD: reaching it means a reader was built + // without installing its key map, and must not be softened into a success with a zero + // count. #[test] fn content_gate_aacs_keys_fail_loud_not_ok_zero() { let mut keys = DecryptKeys::Aacs { @@ -751,9 +742,8 @@ mod tests { plaintext } - // CHARACTERIZATION: the CSS arm's per-region re-crack when the cached - // title key goes stale at a VOB region boundary. Delicate logic the - // recovery refactor moves next; see docs/decrypt.md for detail. + // CHARACTERIZATION: the CSS arm's per-region re-crack when the cached title key goes stale + // at a VOB region boundary. Delicate logic the recovery refactor moves next. #[test] fn css_region_change_recracks_the_title_key() { let key_a = [0x11, 0x22, 0x33, 0x44, 0x55]; @@ -823,9 +813,8 @@ mod tests { ); } - // A CLEAR trailing partial is legitimate content and must pass through - // byte-for-byte, not just return `Ok` — a corrupting mutant that still - // returns `Ok` must fail this. See docs/decrypt.md. + // A CLEAR trailing partial is legitimate content and must pass through byte-for-byte, not + // just return `Ok` — a corrupting mutant that still returns `Ok` must fail this. #[test] fn aacs_clear_trailing_partial_passes_through() { let keys = DecryptKeys::Aacs { @@ -966,9 +955,8 @@ mod tests { (plaintext, body) } - // CSS keys are per-VTS/VOB region: must re-crack when the cached key - // stops descrambling, not blindly reapply it — the bug that pixelated - // every DVD rip. See docs/decrypt.md. + // CSS keys are per-VTS/VOB region: must re-crack when the cached key stops descrambling, + // not blindly reapply it — the bug that pixelated every DVD rip. #[test] fn css_rekeys_when_title_key_region_changes() { let key_a = [0x42, 0x13, 0x37, 0xBE, 0xEF]; @@ -1331,9 +1319,8 @@ mod tests { } } - // A forensic range does NOT start on an aligned-unit boundary, so - // `unit_ix = (lba - range_start) / us` must still get the parity right - // for an unaligned `range_start`. See docs/decrypt.md. + // A forensic range does NOT start on an aligned-unit boundary, so `unit_ix = (lba - + // range_start) / us` must still get the parity right for an unaligned `range_start`. #[test] fn read_plan_phase_parity_is_measured_from_an_unaligned_range_start() { use crate::disc::Extent; @@ -1387,9 +1374,8 @@ mod tests { ); } - // An extent whose last whole unit is an alternate-phase unit must still - // drop it — the tail guard is only for a REMNANT shorter than a unit. - // See docs/decrypt.md for the widened-guard failure mode it pins. + // An extent whose last whole unit is an alternate-phase unit must still drop it — the tail + // guard is only for a REMNANT shorter than a unit. #[test] fn read_plan_gates_the_last_whole_unit_of_an_extent_not_just_the_remnant() { use crate::disc::Extent; @@ -1430,9 +1416,9 @@ mod tests { ); } - // Every scheme that CANNOT prove a key answers the same way — the - // property `decrypt_span` exists to hold. CSS is deliberately excluded - // (self-recovers heuristically; cannot PROVE a key wrong). docs/decrypt.md. + // Every scheme that CANNOT prove a key answers the same way — the property `decrypt_span` + // exists to hold. CSS is deliberately excluded (self-recovers heuristically; cannot PROVE a + // key wrong). #[test] fn every_scheme_gives_the_same_verdict_when_no_key_can_be_proven() { use crate::disc::ContentFormat; @@ -1478,9 +1464,9 @@ mod tests { ); } - // An ENCRYPTED unit outside every key-map range must fail, not pass - // through as ciphertext — a CLEAR one outside every range still must - // pass through untouched. Both directions asserted. See docs/decrypt.md. + // An ENCRYPTED unit outside every key-map range must fail, not pass through as ciphertext — + // a CLEAR one outside every range still must pass through untouched. Both directions + // asserted. #[test] fn an_encrypted_unit_outside_every_key_range_fails_instead_of_passing_through() { use crate::disc::ContentFormat; @@ -1640,9 +1626,8 @@ mod tests { } } - // The mapped descramble indexes the committed key pool POSITIONALLY, so - // the ORDER of the `Vec<UnitKey>` a `KeySource` returns is load-bearing — - // nothing here searches the pool. See docs/decrypt.md. + // The mapped descramble indexes the committed key pool POSITIONALLY, so the ORDER of the + // `Vec<UnitKey>` a `KeySource` returns is load-bearing — nothing here searches the pool. #[test] fn mapped_key_selection_is_positional_so_pool_order_matters() { use crate::disc::ContentFormat; @@ -1744,8 +1729,8 @@ mod tests { // ── decrypt_threads resolution (read-only; no global mutation) ───────── - // Default thread count is always usable: >=1, never above MAX_THREADS. - // Reads only; safe alongside other tests. See docs/decrypt.md. + // Default thread count is always usable: >=1, never above MAX_THREADS. Reads only; safe + // alongside other tests. #[test] fn decrypt_threads_within_valid_pool_range() { let n = decrypt_threads(); @@ -1756,9 +1741,8 @@ mod tests { ); } - // The FMTS phase gate picks which half of an interleaved forensic - // segment we decrypt; getting its index arithmetic wrong silently - // decrypts the alternate variant into garbage. See docs/decrypt.md. + // The FMTS phase gate picks which half of an interleaved forensic segment we decrypt; + // getting its index arithmetic wrong silently decrypts the alternate variant into garbage. #[test] fn phase_gate_selects_only_our_parity_of_a_forensic_segment() { use super::{Phase, unit_is_our_phase}; @@ -1800,9 +1784,8 @@ mod tests { assert!(!unit_is_our_phase(33, 30, 3, Phase::Even)); } - // A malformed key map (unit below range start, or zero unit size) must - // return a DEFINED answer, not merely avoid panicking. See docs/decrypt.md - // for the `|| true` non-assertion this replaces. + // A malformed key map (unit below range start, or zero unit size) must return a DEFINED + // answer, not merely avoid panicking. #[test] fn phase_gate_does_not_panic_on_a_malformed_map() { use super::{Phase, unit_is_our_phase}; diff --git a/src/diag.rs b/src/diag.rs index 9c1d5262..a87135d4 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -1,11 +1,10 @@ //! Structured scan diagnostics — the `--log-level 3` self-diagnosing dump. //! -//! Emits one terse line per row (title, cell, stream, decision) under the -//! `tracing` target `freemkv::diag`, routed to `log.txt` at `--log-level 3`. -//! Every line is prefixed `tag=` (`disc`, `title`, `dvd.cell`, `dvd.vattr`, -//! `dvd.aattr`, `bd.clip`, `bd.mark`, `aacs`, `stream`, `decision`) so a log -//! scraper can filter, and raw bytes are shown as `0xNN` beside their decode. -//! This module only reads already-parsed scan state; see docs/diag.md. +//! Emits one terse line per row (title, cell, stream, decision) under the `tracing` target +//! `freemkv::diag`, routed to `log.txt` at `--log-level 3`. Every line is prefixed `tag=` +//! (`disc`, `title`, `dvd.cell`, `dvd.vattr`, `dvd.aattr`, `bd.clip`, `bd.mark`, `aacs`, +//! `stream`, `decision`) so a log scraper can filter, and raw bytes are shown as `0xNN` beside +//! their decode. This module only reads already-parsed scan state. use crate::disc::{ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, Stream}; use crate::ifo::{CellCategory, DvdTitle}; @@ -198,12 +197,11 @@ pub fn dump_dvd_attrs(ts: &crate::ifo::DvdTitleSet) { } } -/// Emit the ACTUAL per-physical-sub-stream AC-3 channel counts read off the VOB -/// during the mux-time sub-stream probe. This is the ground truth the IFO -/// nibble is compared against: each row is `sub_id=0x8x channels=N` for a -/// physical `private_stream_1` AC-3 sub-stream whose first frame was decoded. -/// An empty probe (scrambled / unreadable / short VOB) logs a single -/// `probed=0` line so the absence is explicit in a bug log. See docs/diag.md. +/// Emit the ACTUAL per-physical-sub-stream AC-3 channel counts read off the VOB during the +/// mux-time sub-stream probe. This is the ground truth the IFO nibble is compared against: each +/// row is `sub_id=0x8x channels=N` for a physical `private_stream_1` AC-3 sub-stream whose +/// first frame was decoded. An empty probe (scrambled / unreadable / short VOB) logs a single +/// `probed=0` line so the absence is explicit in a bug log. pub fn dump_dvd_substream_probe(title_id: u16, probed: &std::collections::BTreeMap<u8, u8>) { if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) { return; @@ -271,9 +269,8 @@ fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> V rec } -// Emits the MKV `TrackEntry` elements the muxer is about to write for one -// track (FlagInterlaced, FieldOrder, DefaultDuration, Display dims, -// codecPrivate hex). See docs/diag.md. No-op unless the diag target is on. +// Emits the MKV `TrackEntry` elements the muxer is about to write for one track +// (FlagInterlaced, FieldOrder, DefaultDuration, Display dims, codecPrivate hex). pub(crate) fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) { if !diag_enabled() { return; @@ -466,8 +463,7 @@ pub fn dump_disc(disc: &Disc) { } // The `reason=` token on the main-feature decision row. DERIVED from -// `Disc::CANONICAL_TITLE_ORDER_KEYS`, never restated here — see docs/diag.md -// for why (a prior hand-written copy drifted from the real comparator keys). +// `Disc::CANONICAL_TITLE_ORDER_KEYS`, never restated here. fn main_feature_reason() -> String { let keys: Vec<&str> = Disc::MAIN_FEATURE_ORDER_KEYS .iter() @@ -607,9 +603,8 @@ mod tests { // deleted in favour of the canonical accessors. use crate::disc::{AudioChannels, SampleRate}; - // The main-feature decision row must NAME the comparator's real sort keys, - // not a restated (driftable) copy — see docs/diag.md. Asserts the - // behavioural half first, against the comparator itself with literals. + // The main-feature decision row must NAME the comparator's real sort keys, not a restated + // (driftable) copy. #[test] fn main_feature_reason_names_the_comparators_real_keys() { use crate::disc::{Clip, Disc, DiscTitle}; diff --git a/src/dirimage/encode.rs b/src/dirimage/encode.rs index 5a59baeb..68e95f82 100644 --- a/src/dirimage/encode.rs +++ b/src/dirimage/encode.rs @@ -18,9 +18,6 @@ //! part_start + … File Entries (ICBs) and directory data (FIDs) //! last sector Anchor Volume Descriptor Pointer (copy) //! ``` -//! -//! See `docs/encode.md` for why this is UDF 1.02 with a single Type-1 -//! partition map, and why that makes the module testable against `udf.rs`. use super::layout::{DirNode, Layout}; use crate::error::{Error, Result}; @@ -75,9 +72,8 @@ fn crc16(data: &[u8]) -> u16 { crc } -// ECMA-167 3/7.2 descriptor tag over buf[0..16]. tag_loc: ABSOLUTE for -// volume-space descriptors, PARTITION-RELATIVE inside the partition (else -// a driver rejects the volume, see docs/encode.md); desc_len incl. tag. +// ECMA-167 3/7.2 descriptor tag over buf[0..16]. tag_loc: ABSOLUTE for volume-space +// descriptors, PARTITION-RELATIVE inside the partition; desc_len incl. tag. fn finish_tag(buf: &mut [u8], tag_id: u16, tag_loc: u32, desc_len: usize) { buf[0..2].copy_from_slice(&tag_id.to_le_bytes()); buf[2..4].copy_from_slice(&DESC_VERSION.to_le_bytes()); @@ -152,8 +148,8 @@ fn put_dstring(buf: &mut [u8], s: &str) { buf[buf.len() - 1] = n as u8; } -// OSTA CS0: compression ID 8 (ASCII, one byte/char) else ID 16 (UTF-16BE). -// ASCII not Latin-1 for the 8-bit form on purpose — see docs/encode.md. +// OSTA CS0: compression ID 8 (ASCII, one byte/char) else ID 16 (UTF-16BE). ASCII not Latin-1 +// for the 8-bit form on purpose. pub(super) fn encode_cs0(s: &str) -> Vec<u8> { if s.is_ascii() { let mut v = Vec::with_capacity(1 + s.len()); @@ -391,9 +387,8 @@ fn file_set(volume_id: &str, root_icb: u32, lba: u32) -> Box<[u8; SECTOR]> { /// bit anywhere — the volume is read-only. const PERM_R_X: u32 = 0x0000_1000 | 0x0000_0400 | 0x0000_0080 | 0x0000_0020 | 0x4 | 0x1; -// ECMA-167 4/14.9 File Entry (tag 261, not EFE 266 — see docs/encode.md). -// extents are partition-relative (block, len) pairs, already split so none -// exceeds the 30-bit AD length field. +// ECMA-167 4/14.9 File Entry. extents are partition-relative (block, len) pairs, already split +// so none exceeds the 30-bit AD length field. fn file_entry( is_dir: bool, info_len: u64, @@ -448,9 +443,8 @@ fn file_entry( Ok(s) } -// ECMA-167 4/14.4 File Identifier Descriptor, appended to buf. FIDs pack -// with no padding beyond the mandated 4-byte alignment and may span -// logical blocks — see docs/encode.md for why that's required. +// ECMA-167 4/14.4 File Identifier Descriptor, appended to buf. FIDs pack with no padding beyond +// the mandated 4-byte alignment and may span logical blocks. fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) { let start = buf.len(); let name_field: Vec<u8> = if is_parent { diff --git a/src/dirimage/layout.rs b/src/dirimage/layout.rs index 62520468..7574a6d4 100644 --- a/src/dirimage/layout.rs +++ b/src/dirimage/layout.rs @@ -7,19 +7,17 @@ //! case-collision inside one directory). //! 2. **Assign** blocks. Metadata first, then file data — see //! [`place_video_ts`] for the DVD placement constraint. -//! -//! See `docs/dirimage.md` for the full rationale (Blu-ray vs. DVD placement). use super::encode::{ANCHOR_LBA, MIN_PART_START, SECTOR}; use crate::error::{Error, Result}; use std::path::{Path, PathBuf}; -// First block any FILE DATA may occupy, absolute. Far from zero: bluray.rs -// drops any clip extent whose LBA is 0. See docs/dirimage.md — DATA_FLOOR. +// First block any FILE DATA may occupy, absolute. Far from zero: bluray.rs drops any clip +// extent whose LBA is 0. const DATA_FLOOR: u32 = 4096; -// Largest byte length a single AD may record: 30-bit length field, rounded -// down to a whole number of blocks. See docs/dirimage.md — MAX_AD_BYTES. +// Largest byte length a single AD may record: 30-bit length field, rounded down to a whole +// number of blocks. pub(super) const MAX_AD_BYTES: u64 = 0x3FFF_F800; // Deepest directory nesting represented. Matches `udf.rs`'s `MAX_DIR_DEPTH`; @@ -35,28 +33,27 @@ const MAX_ENTRIES: usize = 100_000; // byte (255 ceiling); 254 leaves no way to produce a value that wraps to 0. const MAX_CS0_NAME_BYTES: usize = 254; -// Most subdirectories one directory may hold: link count is u16 (children + -// self), so `u16::MAX - 1` is the last usable value. Lowered under -// cfg(test) only so the guard is actually exercisable. See docs/dirimage.md. +// Most subdirectories one directory may hold: link count is u16 (children + self), so `u16::MAX +// - 1` is the last usable value. Lowered under cfg(test) only so the guard is actually +// exercisable. #[cfg(not(test))] const MAX_SUBDIRS: usize = (u16::MAX - 1) as usize; #[cfg(test)] const MAX_SUBDIRS: usize = 4; -// One entry per child plus the parent's own must fit the 16-bit field. Must -// stay at MODULE scope: it previously lived inside `#[cfg(test)] mod tests` -// with its own `#[cfg(not(test))]`, so it never compiled at all. See docs/dirimage.md. +// One entry per child plus the parent's own must fit the 16-bit field. Must stay at MODULE +// scope: it previously lived inside `#[cfg(test)] mod tests` with its own `#[cfg(not(test))]`, +// so it never compiled at all. #[cfg(not(test))] const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize); // Largest image this planner will synthesize, in sectors (128 GiB). A -// regenerated/hand-assembled folder can name a DVD VOB offset far beyond the -// content; without a ceiling the image grows to wherever it points. See docs/dirimage.md. +// regenerated/hand-assembled folder can name a DVD VOB offset far beyond the content; without a +// ceiling the image grows to wherever it points. const MAX_IMAGE_SECTORS: u32 = (128u64 * 1024 * 1024 * 1024 / SECTOR as u64) as u32; -// Ceiling on the in-memory metadata region (64 MiB): the entry cap alone -// permits ~205 MB, and the mux holds two of these at once while probing. -// See docs/dirimage.md — MAX_META_BYTES. +// Ceiling on the in-memory metadata region (64 MiB): the entry cap alone permits ~205 MB, and +// the mux holds two of these at once while probing. const MAX_META_BYTES: u64 = 64 * 1024 * 1024; /// The fan-out cap must bite before the global entry cap, or it never fires. @@ -144,8 +141,8 @@ fn dir_bytes(dirs: &[DirNode], files: &[FileNode]) -> usize { // ── Phase 1: walk ─────────────────────────────────────────────────────────── -// Whether a host directory entry belongs in the synthesized image (Finder's -// `.DS_Store`/`._*`, or freemkv's own `.partial`). See docs/dirimage.md. +// Whether a host directory entry belongs in the synthesized image (Finder's `.DS_Store`/`._*`, +// or freemkv's own `.partial`). fn is_excluded(name: &str) -> bool { name.starts_with('.') || name.ends_with(".partial") } @@ -334,9 +331,9 @@ fn be_u32(buf: &[u8], off: usize) -> Option<u32> { Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) } -// Read the first `n` bytes of an IFO so its placement offsets can be -// resolved. Errors propagate — an empty buffer would record NO placement -// constraint and the rip would read the wrong sectors. See docs/dirimage.md. +// Read the first `n` bytes of an IFO so its placement offsets can be resolved. Errors propagate +// — an empty buffer would record NO placement constraint and the rip would read the wrong +// sectors. fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> { use std::io::Read; let mut buf = vec![0u8; n]; @@ -348,9 +345,9 @@ fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> { Ok(buf) } -// Placement order and constraints for a `VIDEO_TS` folder. DVD-Video records -// VOB positions INSIDE the IFOs, as offsets from the IFO's own first sector; -// placement must reproduce them exactly or fail. See docs/dirimage.md. +// Placement order and constraints for a `VIDEO_TS` folder. DVD-Video records VOB positions +// INSIDE the IFOs, as offsets from the IFO's own first sector; placement must reproduce them +// exactly or fail. fn place_video_ts(vts: &mut DirNode, start: u32) -> Result<u32> { let mut order: Vec<usize> = (0..vts.files.len()).collect(); // Canonical on-disc order. Files the naming scheme does not cover (stray @@ -522,9 +519,9 @@ fn metadata_blocks(dir: &DirNode) -> u64 { n } -// Reject a Blu-ray 3D tree: a real 3D disc's `.ssif` ALIASES the same -// sectors as its base/dependent `.m2ts`, which this planner cannot express -// (it would allocate disjoint copies). See docs/dirimage.md — reject_ssif. +// Reject a Blu-ray 3D tree: a real 3D disc's `.ssif` ALIASES the same sectors as its +// base/dependent `.m2ts`, which this planner cannot express (it would allocate disjoint +// copies). fn reject_ssif(root: &DirNode) -> Result<()> { let Some(bdmv) = child_dir(root, "BDMV") else { return Ok(()); @@ -538,9 +535,8 @@ fn reject_ssif(root: &DirNode) -> Result<()> { Ok(()) } -// Plan an image over `root`. `total_sectors` feeds the oversize gate that -// decides what `-t 1` selects; it is the image's OWN size, never padded to -// a media tier. See docs/dirimage.md — plan / Capacity, and what it changes. +// Plan an image over `root`. `total_sectors` feeds the oversize gate that decides what `-t 1` +// selects; it is the image's OWN size, never padded to a media tier. pub(super) fn plan(root: &Path) -> Result<Layout> { let mut entries = 0usize; let mut tree = walk(root, "", 0, &mut entries)?; @@ -659,8 +655,8 @@ pub(super) fn metadata_block_count(root: &DirNode) -> u64 { mod tests { use super::*; - // A file above the 30-bit AD ceiling must split on a block boundary, or - // the next extent's bytes start mid-sector. See docs/dirimage.md — Tests. + // A file above the 30-bit AD ceiling must split on a block boundary, or the next extent's + // bytes start mid-sector. #[test] fn a_file_past_the_ad_ceiling_splits_on_a_block_boundary() { let mut f = FileNode { @@ -692,11 +688,10 @@ mod tests { ); } - /// The metadata ceiling must account for each directory's FID-list sectors, - /// not just one File Entry per node: a wide directory's FID list can span - /// many sectors that the old (dir_count + file_count) count ignored, so an - /// oversized folder could slip past the guard and be materialized in full. - /// See docs/dirimage.md — metadata_ceiling_counts_fid_lists. + /// The metadata ceiling must account for each directory's FID-list sectors, not just one + /// File Entry per node: a wide directory's FID list can span many sectors that the old + /// (dir_count + file_count) count ignored, so an oversized folder could slip past the guard + /// and be materialized in full. #[test] fn metadata_block_count_includes_the_fid_list_sectors() { let files: Vec<FileNode> = (0..300) @@ -770,8 +765,8 @@ mod tests { assert!(!is_excluded("VTS_01_1.VOB")); } - // Two host names the READER collapses into one must be refused by the - // planner. Calls `plan` on a real folder. See docs/dirimage.md — Tests. + // Two host names the READER collapses into one must be refused by the planner. Calls `plan` + // on a real folder. #[test] fn two_names_the_reader_cannot_tell_apart_are_refused() { let dir = std::env::temp_dir().join(format!( @@ -803,8 +798,8 @@ mod tests { ); } - // A name too long for the FID's one-byte length field is refused by the - // planner, on a real folder. See docs/dirimage.md — Tests. + // A name too long for the FID's one-byte length field is refused by the planner, on a real + // folder. #[test] fn an_over_long_name_is_refused_by_the_planner() { let dir = std::env::temp_dir().join(format!( @@ -859,8 +854,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - // The subdirectory cap must keep the 16-bit link count representable. - // Pins the arithmetic; does NOT exercise `walk`. See docs/dirimage.md. + // The subdirectory cap must keep the 16-bit link count representable. Pins the arithmetic; + // does NOT exercise `walk`. #[test] fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() { // Executes the guard rather than restating the constant: link count (child diff --git a/src/dirimage/mod.rs b/src/dirimage/mod.rs index bc37f8fe..a43c3cca 100644 --- a/src/dirimage/mod.rs +++ b/src/dirimage/mod.rs @@ -6,8 +6,6 @@ //! metadata is encoded into RAM by [`encode`], data sectors map to on-demand //! file reads. 3D/SSIF folders are rejected up front //! ([`Error::DirImageSsifUnsupported`]). -//! -//! See docs/dirimage.md for details, unsupported cases, and caveats. mod encode; mod layout; diff --git a/src/dirimage/tests.rs b/src/dirimage/tests.rs index e2169415..1b4a304e 100644 --- a/src/dirimage/tests.rs +++ b/src/dirimage/tests.rs @@ -22,9 +22,8 @@ impl Scratch { Self(p) } - // A monotonic counter, not a timestamp: macOS's clock resolves to only a - // microsecond, which collides across parallel tests sharing a tag. - // See docs/dirimage-tests.md — Scratch::unique_path + // A monotonic counter, not a timestamp: macOS's clock resolves to only a microsecond, which + // collides across parallel tests sharing a tag. fn unique_path(tag: &str) -> PathBuf { static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let uniq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -75,9 +74,8 @@ fn bdmv_scratch() -> (Scratch, Vec<u8>, Vec<u8>) { (s, index, clip) } -// Every Scratch must own a distinct directory, or a parallel test's -// remove_dir_all deletes another's files mid-read. -// See docs/dirimage-tests.md — scratch_paths_are_unique_even_at_clock_resolution +// Every Scratch must own a distinct directory, or a parallel test's remove_dir_all deletes +// another's files mid-read. #[test] fn scratch_paths_are_unique_even_at_clock_resolution() { let paths: Vec<PathBuf> = (0..1000).map(|_| Scratch::unique_path("uniq")).collect(); @@ -260,9 +258,8 @@ fn the_same_folder_synthesizes_the_same_image() { } } -// A directory whose FID list spans several 2048-byte blocks, read back -// through the production parser; also exercises handle-cache eviction. -// See docs/dirimage-tests.md — a_directory_spanning_many_blocks_reads_back_whole +// A directory whose FID list spans several 2048-byte blocks, read back through the production +// parser; also exercises handle-cache eviction. #[test] fn a_directory_spanning_many_blocks_reads_back_whole() { const N: usize = 200; @@ -484,9 +481,8 @@ fn an_oversized_vob_offset_leaves_a_gap_rather_than_failing() { assert!(buf.iter().all(|&b| b == 0)); } -// A file inside a SUBDIRECTORY of VIDEO_TS must still have its data placed. -// Audit finding: the DVD branch's follow-up loop skipped that directory. -// See docs/dirimage-tests.md — a_file_below_video_ts_is_placed_not_just_declared +// A file inside a SUBDIRECTORY of VIDEO_TS must still have its data placed. Audit finding: the +// DVD branch's follow-up loop skipped that directory. #[test] fn a_file_below_video_ts_is_placed_not_just_declared() { let s = Scratch::new("dvdsubdir"); @@ -507,9 +503,8 @@ fn a_file_below_video_ts_is_placed_not_just_declared() { ); } -// A VOBS offset far past the content must be REFUSED, not honoured, or a -// u32 sector count (~8.8 TB) can fill an iso:// destination with zeros. -// See docs/dirimage-tests.md — a_vob_offset_past_the_image_cap_is_refused +// A VOBS offset far past the content must be REFUSED, not honoured, or a u32 sector count (~8.8 +// TB) can fill an iso:// destination with zeros. #[test] fn a_vob_offset_past_the_image_cap_is_refused() { let s = Scratch::new("dvdcap"); @@ -595,9 +590,8 @@ fn minimal_clpi(source_packets: u32) -> Vec<u8> { d } -// A BD folder that enumerates a title, so the AACS content probe has an -// extent to sample; `scrambled` sets the CPI bits and withholds TS sync. -// See docs/dirimage-tests.md — playable_bdmv +// A BD folder that enumerates a title, so the AACS content probe has an extent to sample; +// `scrambled` sets the CPI bits and withholds TS sync. fn playable_bdmv(tag: &str, scrambled: bool) -> Scratch { let s = Scratch::new(tag); let packets = 4096u32; @@ -669,9 +663,8 @@ fn an_aacs_folder_with_scrambled_content_is_rejected() { // `scan_dir` and `mux::resolve::input("dir://…")` once disagreed: only `scan_dir` // re-judged the verdict from CONTENT. Fixed via `apply_folder_encryption_verdict`. -/// The two doors must AGREE: same folder, same verdict, same extents. Stated -/// as a differential, not a bare is_ok(), so it cannot go vacuous. -/// See docs/dirimage-tests.md — both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory +/// The two doors must AGREE: same folder, same verdict, same extents. Stated as a differential, +/// not a bare is_ok(), so it cannot go vacuous. #[test] fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() { // `s` MUST outlive `stream`: the pipeline's producer thread is still @@ -706,9 +699,8 @@ fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() { drop(stream); } -// The other verdict, same door: a truly scrambled folder is refused with the -// TYPED code, not muxed into garbage. Load-bearing half. -// See docs/dirimage-tests.md — a_scrambled_folder_is_refused_through_the_dir_url_door_too +// The other verdict, same door: a truly scrambled folder is refused with the TYPED code, not +// muxed into garbage. Load-bearing half. #[test] fn a_scrambled_folder_is_refused_through_the_dir_url_door_too() { let s = playable_bdmv("dirdoorenc", true); @@ -729,9 +721,8 @@ fn a_scrambled_folder_is_refused_through_the_dir_url_door_too() { // ── External oracle ───────────────────────────────────────────────────────── -/// Write a synthesized image to a real file and ask the OS to mount it — the -/// only check here not circular with the encoder. Ignored by default (shells -/// out to hdiutil). See docs/dirimage-tests.md — write_and_mount_externally +/// Write a synthesized image to a real file and ask the OS to mount it — the only check here +/// not circular with the encoder. Ignored by default (shells out to hdiutil). #[test] #[ignore = "external: shells out to hdiutil/mount"] fn write_and_mount_externally() { @@ -1020,9 +1011,8 @@ fn multi_item_mpls(clip_ids: &[&[u8; 5]]) -> Vec<u8> { buf } -// Clip::feed_span is the INPUT to the whole provenance feature, produced in -// exactly one place; every SeamPlan test synthesizes spans by hand, so -// nothing else checks the producer. See docs/dirimage-tests.md (this fn). +// Clip::feed_span is the INPUT to the whole provenance feature, produced in exactly one place; +// every SeamPlan test synthesizes spans by hand, so nothing else checks the producer. #[test] fn a_multi_clip_playlist_produces_feed_spans_that_tile_the_feed() { let s = Scratch::new("spans"); diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 6c915d6d..aa8a8ac2 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -6,15 +6,13 @@ use crate::mpls; use crate::sector::SectorSource; use crate::udf; -// Stream extensions probed for a BD clip, in priority order: `.m2ts` -// normally, AACS 2.1 uses `.fmts`, 3D uses `.ssif`. HD-DVD's `.evo` is a -// different tree and does NOT belong here. See docs/bluray.md — CLIP_STREAM_EXTS. +// Stream extensions probed for a BD clip, in priority order: `.m2ts` normally, AACS 2.1 uses +// `.fmts`, 3D uses `.ssif`. HD-DVD's `.evo` is a different tree and does NOT belong here. const CLIP_STREAM_EXTS: [&str; 3] = ["m2ts", "fmts", "ssif"]; impl Disc { - // Scan Blu-ray titles from MPLS playlists. `halt` is polled between - // playlists and once more after the loop; a Halted read propagates - // instead of being swallowed. See docs/bluray.md — scan_bluray_titles. + // Scan Blu-ray titles from MPLS playlists. `halt` is polled between playlists and once more + // after the loop; a Halted read propagates instead of being swallowed. pub(super) fn scan_bluray_titles( reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, @@ -56,9 +54,8 @@ impl Disc { Ok(titles) } - // Parse one MPLS playlist into a DiscTitle. `Ok(None)` covers both benign - // misses (unparseable, sub-30s) and deliberate drops; `Err` (only - // Halted) means the scan is over. See docs/bluray.md — parse_playlist. + // Parse one MPLS playlist into a DiscTitle. `Ok(None)` covers both benign misses + // (unparseable, sub-30s) and deliberate drops; `Err` (only Halted) means the scan is over. pub(super) fn parse_playlist( reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, @@ -637,9 +634,8 @@ mod tests { ); } - // At exactly 30s the playlist is kept (`< 30.0` is strict); uses a fully - // wired BDMV so the boundary, not an unresolvable clip, is what's pinned. - // See docs/bluray.md — parse_playlist_keeps_exactly_30_seconds. + // At exactly 30s the playlist is kept (`< 30.0` is strict); uses a fully wired BDMV so the + // boundary, not an unresolvable clip, is what's pinned. #[test] fn parse_playlist_keeps_exactly_30_seconds() { let mut disc = MemDisc::new(); @@ -779,9 +775,8 @@ mod tests { udf::read_filesystem(disc).expect("fs") } - // BDMV with a real 3D layout: `.ssif` under BDMV/STREAM/SSIF/<clip>.ssif - // (unlike make_bdmv_fs_ext) plus a matching .clpi — resolving it latches - // `is_3d = true`. See docs/bluray.md — make_bdmv_fs_ssif. + // BDMV with a real 3D layout: `.ssif` under BDMV/STREAM/SSIF/<clip>.ssif (unlike + // make_bdmv_fs_ext) plus a matching.clpi — resolving it latches `is_3d = true`. fn make_bdmv_fs_ssif( disc: &mut MemDisc, clips: &[( @@ -908,9 +903,8 @@ mod tests { ); } - // AACS 2.1: feature clip is `00001.fmts`, not `.m2ts`; CLIP_STREAM_EXTS - // fallback must still resolve the extent (used to error → empty rip). - // See docs/bluray.md — parse_playlist_fmts_clip_resolves_extent. + // AACS 2.1: feature clip is `00001.fmts`, not `.m2ts`; CLIP_STREAM_EXTS fallback must still + // resolve the extent (used to error → empty rip). #[test] fn parse_playlist_fmts_clip_resolves_extent() { let mut disc = MemDisc::new(); @@ -939,9 +933,8 @@ mod tests { assert_eq!(t.extents[0].sector_count, 1000); } - // 0.31.0 dedup path: a playlist referencing the same clip_id from - // multiple PlayItems must count extents/bytes exactly once, though each - // PlayItem still gets a Clip entry. See docs/bluray.md — dedup test. + // 0.31.0 dedup path: a playlist referencing the same clip_id from multiple PlayItems must + // count extents/bytes exactly once, though each PlayItem still gets a Clip entry. #[test] fn parse_playlist_dedups_repeated_clip_extents_and_size() { let mut disc = MemDisc::new(); @@ -1018,9 +1011,8 @@ mod tests { assert_eq!(t.size_bytes, (4000 + 2000) * 192); } - // A clip whose .clpi is missing must yield NO title, not one advertising - // the full runtime with none of its bytes, and must log the read's OWN - // error code. See docs/bluray.md — parse_playlist_missing_clpi_yields_no_title. + // A clip whose.clpi is missing must yield NO title, not one advertising the full runtime + // with none of its bytes, and must log the read's OWN error code. #[test] fn parse_playlist_missing_clpi_yields_no_title() { let mut disc = MemDisc::new(); @@ -1098,9 +1090,8 @@ mod tests { ); } - // A clip stream whose ICB declares an UNRECORDED (ECMA-167 4/14.14.1.1 - // type-1) extent must not yield a title: neither reading nor dropping it - // is truthful. See docs/bluray.md — parse_playlist_unrecorded_extent_yields_no_title. + // A clip stream whose ICB declares an UNRECORDED (ECMA-167 4/14.14.1.1 type-1) extent must + // not yield a title: neither reading nor dropping it is truthful. #[test] fn parse_playlist_unrecorded_extent_yields_no_title() { let mut disc = MemDisc::new(); @@ -1186,9 +1177,8 @@ mod tests { ); } - // An unrecorded extent was never the only way a clip fails to resolve: a - // scratched-sector DiscRead on the .m2ts ICB must drop the title too, not - // fall through to "absent". See docs/bluray.md — parse_playlist_unreadable_clip_icb_yields_no_title. + // An unrecorded extent was never the only way a clip fails to resolve: a scratched-sector + // DiscRead on the.m2ts ICB must drop the title too, not fall through to "absent". #[test] fn parse_playlist_unreadable_clip_icb_yields_no_title() { let mut disc = MemDisc::new(); @@ -1275,9 +1265,8 @@ mod tests { ); } - // A non-absence SSIF failure that the .m2ts fallback papers over must be - // LOGGED with its own code, while the (degraded 2D) title still ships. - // See docs/bluray.md — parse_playlist_logs_a_non_absence_ssif_failure_the_m2ts_fallback_hid. + // A non-absence SSIF failure that the.m2ts fallback papers over must be LOGGED with its own + // code, while the (degraded 2D) title still ships. #[test] fn parse_playlist_logs_a_non_absence_ssif_failure_the_m2ts_fallback_hid() { let mut disc = MemDisc::new(); @@ -1930,9 +1919,8 @@ mod tests { assert_eq!(t.playlist_id, 0); } - // A filename >= 5 bytes but NOT ending in ".mpls" must not have its last - // 5 bytes stripped; the whole string fails the numeric parse instead. - // See docs/bluray.md — parse_playlist_id_falls_back_to_zero_when_suffix_is_not_mpls. + // A filename >= 5 bytes but NOT ending in ".mpls" must not have its last 5 bytes stripped; + // the whole string fails the numeric parse instead. #[test] fn parse_playlist_id_falls_back_to_zero_when_suffix_is_not_mpls() { let mut disc = MemDisc::new(); @@ -2037,9 +2025,8 @@ mod tests { assert_eq!(titles[0].playlist_id, 800); } - // A non-directory PLAYLIST entry not ending in ".mpls" must be skipped - // even if its content parses as a valid MPLS: extension gating, not - // content sniffing. See docs/bluray.md — scan_bluray_titles_skips_non_mpls_extension_file. + // A non-directory PLAYLIST entry not ending in ".mpls" must be skipped even if its content + // parses as a valid MPLS: extension gating, not content sniffing. #[test] fn scan_bluray_titles_skips_non_mpls_extension_file() { let mut disc = MemDisc::new(); @@ -2098,9 +2085,8 @@ mod tests { ); } - // A SectorSource that fails every read in `halt_range` with Error::Halted - // — how a live drive behaves once Stop is pressed. Reads outside the - // range succeed. See docs/bluray.md — HaltingReader. + // A SectorSource that fails every read in `halt_range` with Error::Halted — how a live + // drive behaves once Stop is pressed. Reads outside the range succeed. struct HaltingReader<'a> { inner: &'a mut MemDisc, halt_range: std::ops::Range<u32>, @@ -2179,9 +2165,9 @@ mod tests { udf::read_filesystem(disc).expect("fs") } - // A Stop on a live drive never touches ScanOptions::halt; the enumerator - // must not swallow a Halted read into a successful (shorter) scan. Halt - // lands on the LAST playlist deliberately. See docs/bluray.md — halted_playlist_read_is_not_reported_as_a_shorter_disc. + // A Stop on a live drive never touches ScanOptions::halt; the enumerator must not swallow a + // Halted read into a successful (shorter) scan. Halt lands on the LAST playlist + // deliberately. #[test] fn halted_playlist_read_is_not_reported_as_a_shorter_disc() { let mut disc = MemDisc::new(); @@ -2208,9 +2194,8 @@ mod tests { ); } - // The same cancel landing on a .clpi read must not be classified as an - // unresolvable clip either — it must propagate, not be logged as a - // disc defect. See docs/bluray.md — halted_clpi_read_is_not_accounted_as_an_unresolvable_clip. + // The same cancel landing on a.clpi read must not be classified as an unresolvable clip + // either — it must propagate, not be logged as a disc defect. #[test] fn halted_clpi_read_is_not_accounted_as_an_unresolvable_clip() { let mut disc = MemDisc::new(); @@ -2240,9 +2225,8 @@ mod tests { ); } - // And the same cancel landing on file_extents (the clip's ICB, not its - // CLIPINF) must propagate too, not yield a title with runtime counted and - // zero bytes behind it. See docs/bluray.md — halted_extent_resolve_is_not_a_title_missing_its_clip. + // And the same cancel landing on file_extents (the clip's ICB, not its CLIPINF) must + // propagate too, not yield a title with runtime counted and zero bytes behind it. #[test] fn halted_extent_resolve_is_not_a_title_missing_its_clip() { let mut disc = MemDisc::new(); @@ -2371,9 +2355,8 @@ mod tests { } } - // A non-.xml file must be ignored even if its content looks like valid - // meta XML: extension gating, not content sniffing, decides eligibility. - // See docs/bluray.md — read_meta_title_ignores_non_xml_file_regardless_of_content. + // A non-.xml file must be ignored even if its content looks like valid meta XML: extension + // gating, not content sniffing, decides eligibility. #[test] fn read_meta_title_ignores_non_xml_file_regardless_of_content() { let mut disc = MemDisc::new(); diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 73f7ecb9..5505419d 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -6,9 +6,8 @@ use crate::sector::SectorSource; use crate::udf; impl Disc { - // Scan DVD titles; a cancelled read is Err(Error::Halted), never an empty - // list. Also returns the nav-resolved main feature, or None. - // See docs/dvd.md — Disc::scan_dvd_titles halt handling + // Scan DVD titles; a cancelled read is Err(Error::Halted), never an empty list. Also + // returns the nav-resolved main feature, or None. pub(super) fn scan_dvd_titles( reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, @@ -72,7 +71,7 @@ impl Disc { // TODO(spec): measured colour/TFF await a CodecParser->title channel. measured_cicp: None, }); - // TODO(spec): DefaultDuration/cadence deferred. See docs/dvd.md — DefaultDuration / cadence TODO + // TODO(spec): DefaultDuration/cadence deferred. // PID derives from private_stream_1 sub-id via dvd_audio_pid; // MP1/MP2 (no sub-id) fall back to 0xBD00+ordinal. @@ -388,7 +387,6 @@ mod tests { } // IFO builders (DVD-Video spec, offsets per ifo.rs). VIDEO_TS.IFO layout. - // See docs/dvd.md — Test fixture builders: build_vmg / build_vts fn build_vmg( titles: &[( u16, /*chapters*/ @@ -427,7 +425,6 @@ mod tests { } // VTS_XX_0.IFO + PGCIT + PGC layout (offsets, vtstt_vobs vs vtsm_vobs). - // See docs/dvd.md — Test fixture builders: build_vmg / build_vts #[allow(clippy::too_many_arguments)] fn build_vts( vob_start: u32, @@ -498,9 +495,8 @@ mod tests { d } - // Tests. HaltingReader fails every read at/above halt_at with - // Error::Halted, mimicking a live drive after a Stop. - // See docs/dvd.md — HaltingReader test double + // Tests. HaltingReader fails every read at/above halt_at with Error::Halted, mimicking a + // live drive after a Stop. struct HaltingReader<'a> { inner: &'a mut MemDisc, halt_at: u32, @@ -520,9 +516,9 @@ mod tests { } } - // A drive Stop must surface as Err(Error::Halted), not a truncated/empty - // scan. Regression: two prior swallow sites (parse_vmg placeholder entry; - // scan_dvd_titles's Err(_) => Vec::new()). See docs/dvd.md — Test: halted_ifo_read_is_not_reported_as_a_shorter_disc + // A drive Stop must surface as Err(Error::Halted), not a truncated/empty scan. Regression: + // two prior swallow sites (parse_vmg placeholder entry; scan_dvd_titles's Err(_) => + // Vec::new()). #[test] fn halted_ifo_read_is_not_reported_as_a_shorter_disc() { // Two title sets; only the second title set's CONTENT read is halted. @@ -660,9 +656,8 @@ mod tests { assert_eq!(t.content_format, ContentFormat::MpegPs); } - // Regression: vob_start must come from Title VOBS (0xC4), not menu VOBS - // (0xC0) -- else a per-title menu prepends and the rip opens on the wrong - // frame. See docs/dvd.md — Test: scan_dvd_titles_uses_title_vobs_not_menu_vobs + // Regression: vob_start must come from Title VOBS (0xC4), not menu VOBS (0xC0) -- else a + // per-title menu prepends and the rip opens on the wrong frame. #[test] fn scan_dvd_titles_uses_title_vobs_not_menu_vobs() { let mut disc = MemDisc::new(); @@ -711,9 +706,8 @@ mod tests { ); } - // ABSOLUTE-REBASE regression: start_lba must sum THREE distinct, non- - // overlapping terms (ifo_lba + vtstt_vobs + cell first_sector), not just - // two. See docs/dvd.md — Test: scan_dvd_titles_extent_is_absolute_three_term_sum + // ABSOLUTE-REBASE regression: start_lba must sum THREE distinct, non- overlapping terms + // (ifo_lba + vtstt_vobs + cell first_sector), not just two. #[test] fn scan_dvd_titles_extent_is_absolute_three_term_sum() { let mut disc = MemDisc::new(); @@ -997,7 +991,6 @@ mod tests { } // LPCM (coding_mode 4) at audio pos 1 must route to 0xBDA1, not the AC-3 space. - // See docs/dvd.md — Test: scan_dvd_titles_lpcm_routes_to_a0_pid_range #[test] fn scan_dvd_titles_lpcm_routes_to_a0_pid_range() { let mut disc = MemDisc::new(); @@ -1057,7 +1050,6 @@ mod tests { } // A multi-subtitle VTS must emit one Subtitle per entry, distinct PIDs. - // See docs/dvd.md — Test: scan_dvd_titles_multiple_vobsub_tracks_distinct_pids #[test] fn scan_dvd_titles_multiple_vobsub_tracks_distinct_pids() { let mut disc = MemDisc::new(); @@ -1219,7 +1211,6 @@ mod tests { } // Stamps a First-Play PGC with an unconditional JumpTT ttn pre-command. - // See docs/dvd.md — stamp_first_play_jumptt test helper fn stamp_first_play_jumptt(vmg: &mut [u8], ttn: u8) { const FP_PGC_PTR: usize = 0x84; // u32 byte offset of the First-Play PGC const CMD_TBL_PTR: usize = 0xE4; // u16 cmd-table offset (rel to PGC) @@ -1234,9 +1225,8 @@ mod tests { vmg[tbl + 8..tbl + 16].copy_from_slice(&[0x30, 0x02, 0, 0, 0, ttn, 0, 0]); } - // First-Play nav promotion (issue #40): resolve_main_title's (vtsn, vts_ttn) - // must join to the running title_number and surface as nav_feature. - // See docs/dvd.md — Test: scan_dvd_titles_nav_promotes_first_play_target + // First-Play nav promotion (issue #40): resolve_main_title's (vtsn, vts_ttn) must join to + // the running title_number and surface as nav_feature. #[test] fn scan_dvd_titles_nav_promotes_first_play_target() { let mut disc = MemDisc::new(); @@ -1356,9 +1346,8 @@ mod tests { d } - // A leading interleaved-angle sub-block cell (category 0x90) must be - // dropped from muxed extents; chapters shift earlier by its duration. - // See docs/dvd.md — Test: scan_dvd_titles_drops_leading_scene_index_cell + // A leading interleaved-angle sub-block cell (category 0x90) must be dropped from muxed + // extents; chapters shift earlier by its duration. #[test] fn scan_dvd_titles_drops_leading_scene_index_cell() { let mut disc = MemDisc::new(); @@ -1458,9 +1447,8 @@ mod tests { assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01); } - // MP1/MP2 audio (no sub-stream id) falls back to PID 0xBD00 + i; two such - // streams must land on distinct 0xBD00/0xBD01, pinning the `+` not `-`/`*`. - // See docs/dvd.md — Test: scan_dvd_titles_mp2_audio_pid_fallback_is_additive + // MP1/MP2 audio (no sub-stream id) falls back to PID 0xBD00 + i; two such streams must land + // on distinct 0xBD00/0xBD01, pinning the `+` not `-`/`*`. #[test] fn scan_dvd_titles_mp2_audio_pid_fallback_is_additive() { let mut disc = MemDisc::new(); diff --git a/src/disc/dvd_audio_probe.rs b/src/disc/dvd_audio_probe.rs index 9f154ac9..d3610fdb 100644 --- a/src/disc/dvd_audio_probe.rs +++ b/src/disc/dvd_audio_probe.rs @@ -1,12 +1,11 @@ //! Physical AC-3 sub-stream probing for DVD audio routing. //! -//! On some discs the physical `private_stream_1` sub-stream order does not -//! match the IFO's declared audio stream order, so ordinal assignment -//! (`ifo::assign_audio_sub_stream_ids`) can mux the wrong physical track -//! (e.g. a 2.0 down-mix labelled 5.1). This module probes each physical -//! AC-3 sub-stream's REAL channel count from the VOB and re-routes each -//! declared stream to the matching sub-stream, falling back to ordinal -//! mapping when the probe yields nothing. See docs/dvd-audio-probe.md. +//! On some discs the physical `private_stream_1` sub-stream order does not match the IFO's +//! declared audio stream order, so ordinal assignment (`ifo::assign_audio_sub_stream_ids`) can +//! mux the wrong physical track (e.g. a 2.0 down-mix labelled 5.1). This module probes each +//! physical AC-3 sub-stream's REAL channel count from the VOB and re-routes each declared +//! stream to the matching sub-stream, falling back to ordinal mapping when the probe yields +//! nothing. use crate::disc::Stream; use crate::mux::codec::ac3; @@ -14,19 +13,17 @@ use crate::mux::ps::PsDemuxer; use crate::sector::SectorSource; use std::collections::BTreeMap; -// Sectors of the first feature extent to probe. 512 (1 MiB) was too short on -// a real disc; 1024 (2 MiB) reliably reaches every physical AC-3 sub-stream. -// See docs/dvd-audio-probe.md#probe_sectors-sizing. +// Sectors of the first feature extent to probe. 512 (1 MiB) was too short on a real disc; 1024 +// (2 MiB) reliably reaches every physical AC-3 sub-stream. const PROBE_SECTORS: u16 = 1024; -/// Decode the real per-sub-stream AC-3 channel count from a buffer of decrypted -/// MPEG-PS (DVD VOB) bytes. Demuxes `private_stream_1` (0xBD), and for each -/// AC-3 sub-stream id (`0x80..=0x87`) records the MAXIMUM channel count seen -/// across EVERY decodable frame — the max, not the first frame, because a -/// sub-stream's opening frames are often an unrepresentative logo/warning bed -/// (see docs/dvd-audio-probe.md). Pure — takes the already-read bytes, never -/// touches the disc. Returns a map `sub_id -> max channels`; absent for -/// sub-streams that never appear or carry no decodable BSI bits. +/// Decode the real per-sub-stream AC-3 channel count from a buffer of decrypted MPEG-PS (DVD +/// VOB) bytes. Demuxes `private_stream_1` (0xBD), and for each AC-3 sub-stream id +/// (`0x80..=0x87`) records the MAXIMUM channel count seen across EVERY decodable frame — the +/// max, not the first frame, because a sub-stream's opening frames are often an +/// unrepresentative logo/warning bed. Pure — takes the already-read bytes, never touches the +/// disc. Returns a map `sub_id -> max channels`; absent for sub-streams that never appear or +/// carry no decodable BSI bits. pub fn probe_ac3_substream_channels(ps_bytes: &[u8]) -> BTreeMap<u8, u8> { let mut found: BTreeMap<u8, u8> = BTreeMap::new(); let mut demux = PsDemuxer::new(); @@ -78,14 +75,12 @@ fn max_substream_channels(data: &[u8]) -> Option<u8> { best } -/// Re-route the title's declared AC-3 audio streams onto the physical -/// sub-stream ids whose REAL channel counts match, using a probed -/// `sub_id -> channels` map. For each declared AC-3 audio stream (in IFO -/// order), picks the physical `0x8x` sub-stream whose probed channel count -/// equals the declared count, never reusing a claimed sub-stream, and writes -/// its PID (`0xBD00 | sub_id`) back onto the `Stream::Audio`. Conservative: -/// only reassigns when a better match exists (see docs/dvd-audio-probe.md). -/// Returns the number of streams whose PID was changed. +/// Re-route the title's declared AC-3 audio streams onto the physical sub-stream ids whose REAL +/// channel counts match, using a probed `sub_id -> channels` map. For each declared AC-3 audio +/// stream (in IFO order), picks the physical `0x8x` sub-stream whose probed channel count +/// equals the declared count, never reusing a claimed sub-stream, and writes its PID (`0xBD00 | +/// sub_id`) back onto the `Stream::Audio`. Conservative: only reassigns when a better match +/// exists. Returns the number of streams whose PID was changed. pub fn remap_audio_pids(streams: &mut [Stream], probed: &BTreeMap<u8, u8>) -> usize { if probed.is_empty() { return 0; @@ -194,9 +189,9 @@ mod tests { }; use crate::sector::SectorSource; - // Builds a correctly-SIZED AC-3 frame (128 bytes, matching frmsizecod=0) - // whose `acmod`/`lfeon` encode a known channel count, via a bit writer so - // the test never hand-miscomputes the lfeon offset. See docs/dvd-audio-probe.md. + // Builds a correctly-SIZED AC-3 frame (128 bytes, matching frmsizecod=0) whose + // `acmod`/`lfeon` encode a known channel count, via a bit writer so the test never + // hand-miscomputes the lfeon offset. fn ac3_frame(acmod: u8, lfeon: bool) -> Vec<u8> { let mut bits: Vec<u8> = Vec::new(); let push = |val: u32, n: usize, bits: &mut Vec<u8>| { @@ -239,9 +234,8 @@ mod tests { frame } - // Builds a minimal `private_stream_1` PES carrying `frames` for `sub_id`, - // mirroring the on-disc layout the PS demux expects. See - // docs/dvd-audio-probe.md#test-helper-notes. + // Builds a minimal `private_stream_1` PES carrying `frames` for `sub_id`, mirroring the + // on-disc layout the PS demux expects. fn ps_ac3_frames(sub_id: u8, frames: &[Vec<u8>]) -> Vec<u8> { // PES sub-header for AC-3: sub_id + frame_count + 2-byte access ptr. let mut payload = vec![sub_id, frames.len() as u8, 0x00, 0x04]; @@ -287,9 +281,8 @@ mod tests { assert_eq!(probed.get(&0x81), Some(&6), "0x81 is the 5.1 main mix"); } - // Real-disc regression: the probe must read each sub-stream's TRUE - // (max-mix) channel count without cross-contaminating between sub-streams. - // See docs/dvd-audio-probe.md#probe_reads_max_channels_no_cross_contamination. + // Real-disc regression: the probe must read each sub-stream's TRUE (max-mix) channel count + // without cross-contaminating between sub-streams. #[test] fn probe_reads_max_channels_no_cross_contamination() { let mut bytes = Vec::new(); @@ -400,9 +393,8 @@ mod tests { assert_eq!(a.pid, 0xBD80, "no probe data → keep ordinal"); } - // Mutation guard for `pos + rel` (not `pos - rel`, which could underflow - // `usize`) as the sync's true absolute position. See - // docs/dvd-audio-probe.md#max_substream_channels_locates_sync_after_leading_non_sync_bytes. + // Mutation guard for `pos + rel` (not `pos - rel`, which could underflow `usize`) as the + // sync's true absolute position. #[test] fn max_substream_channels_locates_sync_after_leading_non_sync_bytes() { let mut data = vec![0xAA, 0xAA, 0xAA]; // no 0x0B77 pattern in here @@ -414,9 +406,8 @@ mod tests { ); } - // On an unmappable AC-3 size, must fall back to a forward `start + 2` - // rescan (not loop or overshoot). See - // docs/dvd-audio-probe.md#unmappable-size-fallback-tests. + // On an unmappable AC-3 size, must fall back to a forward `start + 2` rescan (not loop or + // overshoot). #[test] fn max_substream_channels_unmappable_size_steps_forward_by_two() { let mut real = ac3_frame(2, false); @@ -437,8 +428,7 @@ mod tests { ); } - // Same fallback, sync at offset 0 (`start - 2` would underflow). See - // docs/dvd-audio-probe.md#unmappable-size-fallback-tests. + // Same fallback, sync at offset 0 (`start - 2` would underflow). #[test] fn max_substream_channels_unmappable_size_at_start_steps_forward_not_back() { let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0xF8]; // bogus header, offsets 0..6 @@ -450,8 +440,7 @@ mod tests { ); } - // Mutation guard: current sub-stream must be read via `pid & 0x00FF`, not - // `|`/`^`. See docs/dvd-audio-probe.md#remap_reads_current_substream_via_and_not_or_or_xor. + // Mutation guard: current sub-stream must be read via `pid & 0x00FF`, not `|`/`^`. #[test] fn remap_reads_current_substream_via_and_not_or_or_xor() { let mut probed = BTreeMap::new(); @@ -493,9 +482,8 @@ mod tests { } } - // End-to-end `probe_and_remap`: real-disc-shaped MpegPs title, 0x80=2.0 - // down-mix / 0x81=real 5.1, must re-route to 0xBD81. See - // docs/dvd-audio-probe.md#probe_and_remap_reroutes_swapped_substream_scenario_end_to_end. + // End-to-end `probe_and_remap`: real-disc-shaped MpegPs title, 0x80=2.0 down-mix / + // 0x81=real 5.1, must re-route to 0xBD81. #[test] fn probe_and_remap_reroutes_swapped_substream_scenario_end_to_end() { let mut bytes = ps_ac3(0x80, 2, false); // physical 0x80 = 2.0 down-mix diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 5ebacca7..3ef118ef 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -16,10 +16,9 @@ pub(super) struct HandshakeResult { /// instead of a bare "unavailable" — the difference between a diagnosable log /// and archaeology. pub read_data_key_err: Option<u16>, - /// True when the VID came from an unlocker that unlocked the drive, so - /// bus encryption is already removed AT THE DRIVE (firmware, not AKE). - /// The bus-key gate must credit this the same as a cert `read_data_key`. - /// See docs/encrypt.md — HandshakeResult::drive_unlocked. + /// True when the VID came from an unlocker that unlocked the drive, so bus encryption is + /// already removed AT THE DRIVE (firmware, not AKE). The bus-key gate must credit this the + /// same as a cert `read_data_key`. pub drive_unlocked: bool, } @@ -36,9 +35,8 @@ impl std::fmt::Debug for HandshakeResult { } } -// Single source of truth for "is AACS bus encryption gone for this scan?". -// Add a NEW removal mechanism HERE, never in the gate. -// See docs/encrypt.md — bus_encryption_removed. +// Single source of truth for "is AACS bus encryption gone for this scan?". Add a NEW removal +// mechanism HERE, never in the gate. fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResult>) -> bool { if !bus_encryption { return true; // never had it → nothing to remove @@ -49,9 +47,8 @@ fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResu } } -// libfreemkv-side driver for the AACS cert route: owns host-cert collection, -// dispatches mutual-auth to the freemkv-unlock AACS unlocker. -// See docs/encrypt.md — AacsCertUnlocker. +// libfreemkv-side driver for the AACS cert route: owns host-cert collection, dispatches +// mutual-auth to the freemkv-unlock AACS unlocker. struct AacsCertUnlocker<'a> { opts: &'a ScanOptions, } @@ -68,9 +65,8 @@ enum CertUnlockFailure { } impl AacsCertUnlocker<'_> { - // Runs the host-cert mutual-auth handshake and returns a CertUnlockFailure - // on every no-VID outcome. - // See docs/encrypt.md — AacsCertUnlocker::authenticate. + // Runs the host-cert mutual-auth handshake and returns a CertUnlockFailure on every no-VID + // outcome. fn authenticate( &self, session: &mut crate::drive::Drive, @@ -138,9 +134,8 @@ impl AacsCertUnlocker<'_> { } } -// Maps CertUnlockFailure to the Error variant do_handshake_cert has always -// surfaced, so scan_with's rendering stays unchanged. -// See docs/encrypt.md — unlock_error_to_error. +// Maps CertUnlockFailure to the Error variant do_handshake_cert has always surfaced, so +// scan_with's rendering stays unchanged. fn unlock_error_to_error(e: &CertUnlockFailure) -> Error { use freemkv_unlock::UnlockError; match e { @@ -172,17 +167,15 @@ fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutco } } -// Did the cert handshake actually carry a Volume ID? Extracted so it can be -// tested as a VALUE instead of asserting on a racy tracing field. -// See docs/encrypt.md — handshake_has_volume_id. +// Did the cert handshake actually carry a Volume ID? Extracted so it can be tested as a VALUE +// instead of asserting on a racy tracing field. fn handshake_has_volume_id(h: &HandshakeResult) -> bool { h.volume_id != [0u8; 16] } impl Disc { - // SCSI handshake — drives the VID-acquisition flow via do_handshake_cert - // and returns a structured HandshakeResult for downstream key resolution. - // See docs/encrypt.md — Disc::do_handshake. + // SCSI handshake — drives the VID-acquisition flow via do_handshake_cert and returns a + // structured HandshakeResult for downstream key resolution. pub(super) fn do_handshake( session: &mut crate::drive::Drive, opts: &ScanOptions, @@ -204,9 +197,8 @@ impl Disc { (result, err) } - // Cert-based AACS handshake — the cert route for VID acquisition. Checks - // for an OEM VID stashed at drive init() first; skips cert auth if found. - // See docs/encrypt.md — Disc::do_handshake_cert. + // Cert-based AACS handshake — the cert route for VID acquisition. Checks for an OEM VID + // stashed at drive init() first; skips cert auth if found. fn do_handshake_cert( session: &mut crate::drive::Drive, opts: &ScanOptions, @@ -272,9 +264,8 @@ impl Disc { } } - // Collects every AACS host cert the caller carries, from DriveCredentials - // AND the key-source layer, unioned. Empty means the graceful no-cert path. - // See docs/encrypt.md — Disc::collect_host_certs. + // Collects every AACS host cert the caller carries, from DriveCredentials AND the + // key-source layer, unioned. Empty means the graceful no-cert path. fn collect_host_certs( opts: &ScanOptions, mkb: Option<u32>, @@ -285,9 +276,8 @@ impl Disc { crate::aacs::host_certs::collect_host_certs(opts, mkb) } - // Builds a keys-free AACS state carrying only the Volume ID (+ version), - // for callers that resolve Unit Keys out-of-band with the keydb disabled. - // See docs/encrypt.md — Disc::resolve_vid_only. + // Builds a keys-free AACS state carrying only the Volume ID (+ version), for callers that + // resolve Unit Keys out-of-band with the keydb disabled. pub(super) fn resolve_vid_only( udf_fs: &udf::UdfFs, reader: &mut dyn SectorSource, @@ -553,9 +543,8 @@ mod tests { v } - // One Type-and-Version record (type 0x10), version BE u32 @ offset 8, - // then trailing zero padding. - // See docs/encrypt.md — build_mkb (test helper). + // One Type-and-Version record (type 0x10), version BE u32 @ offset 8, then trailing zero + // padding. fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> { let mut v = Vec::new(); // Type 0x10 record, length 16 (>= 12 so version is read). @@ -642,9 +631,8 @@ mod tests { assert!(st.bus_encryption, "cert bus_encryption bit must propagate"); } - // No content cert → version defaults to UHD (major 2), matching - // read_aacs_version (audit #4). bus_encryption false (unreadable → off). - // See docs/encrypt.md — resolve_vid_only_no_cert_defaults_version_uhd. + // No content cert → version defaults to UHD (major 2), matching read_aacs_version (audit + // #4). bus_encryption false (unreadable → off). #[test] fn resolve_vid_only_no_cert_defaults_version_uhd() { let mut disc = MemDisc::new(); @@ -962,7 +950,6 @@ mod tests { } // Tests: read_vid_oem and collect_host_certs coverage notes. - // See docs/encrypt.md — Skipped/deferred test coverage. fn fake_cert(tag: u8) -> aacs::types::HostCert { aacs::types::HostCert { diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 3a588cc3..3786885e 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -6,8 +6,6 @@ //! applies AACS / CSS in-place. This module is the focused per-file producer: //! tree walk, host-path mapping + sanitization, per-VTS CSS key grouping, //! decrypt-and-stream-to-disk, sparse-gap handling, and truncate + rename. -// See docs/extract.md — relation to the sweep/patch ISO recovery passes and -// the numeric-only error code policy. use super::Disc; use crate::decrypt::DecryptKeys; @@ -107,8 +105,6 @@ struct PlannedFile { } impl Disc { - // See docs/extract.md — reader/extent-read ordering, per-VTS CSS key - // resolution, and undecryptable-unit loss accounting. /// Extract this disc's **decrypted file tree** to `dest`. 1-shot, /// decrypt-only, no recovery loop. /// @@ -271,9 +267,8 @@ impl Disc { Ok(result) } - // Resolves the CSS title key for a VTS group from its scrambled title-VOB - // sectors. MUST surface a failed crack as a hard error, never silently - // fall back to the disc-wide key — see docs/extract.md for why. + // Resolves the CSS title key for a VTS group from its scrambled title-VOB sectors. MUST + // surface a failed crack as a hard error, never silently fall back to the disc-wide key. fn resolve_vts_key<S: SectorSource>( &self, vts: &str, @@ -328,9 +323,8 @@ impl Disc { } } -// A borrowing `SectorSource` wrapper: lets the decrypting decorator "own" an -// inner source for its lifetime while the caller keeps the underlying -// `&mut dyn SectorSource` — see docs/extract.md for why (vs. the mux highway). +// A borrowing `SectorSource` wrapper: lets the decrypting decorator "own" an inner source for +// its lifetime while the caller keeps the underlying `&mut dyn SectorSource` struct Borrowed<'a>(&'a mut dyn SectorSource); impl SectorSource for Borrowed<'_> { @@ -618,9 +612,8 @@ fn extract_one_file<S: SectorSource>( Ok((fr, false)) } -// Sizes the next FILE-ANCHORED content read in whole AACS units, capped at -// READ_BATCH_SECTORS, rounded DOWN to 3-sector units unless it's the -// extent's final tail — see docs/extract.md for the trailing-partial rule. +// Sizes the next FILE-ANCHORED content read in whole AACS units, capped at READ_BATCH_SECTORS, +// rounded DOWN to 3-sector units unless it's the extent's final tail. fn whole_unit_batch(remaining: u32) -> u32 { let mut batch = remaining.min(READ_BATCH_SECTORS); if batch >= AACS_UNIT_SECTORS && batch < remaining { @@ -753,9 +746,8 @@ fn available_space(dir: &Path) -> Option<u64> { Some(avail) } -// Windows has no `statvfs`; queries free space via `GetDiskFreeSpaceExW` -// (declared directly against kernel32, matching `scsi::windows`) so the -// free-space gate still runs — see docs/extract.md for why this matters. +// Windows has no `statvfs`; queries free space via `GetDiskFreeSpaceExW` (declared directly +// against kernel32, matching `scsi::windows`) so the free-space gate still runs. #[cfg(windows)] fn available_space(dir: &Path) -> Option<u64> { use std::os::windows::ffi::OsStrExt; @@ -805,9 +797,8 @@ fn dir_is_non_empty(dir: &Path) -> bool { .unwrap_or(false) } -// Sanitizes ONE disc-path component: rejects `..`, host-illegal chars, -// control bytes; strips trailing dot/space (Windows); substitutes (not -// rejects) a Windows reserved device name — see docs/extract.md for why. +// Sanitizes ONE disc-path component: rejects `..`, host-illegal chars, control bytes; strips +// trailing dot/space (Windows); substitutes (not rejects) a Windows reserved device name. fn sanitize_component(name: &str) -> Result<String> { if name == ".." || name == "." { return Err(Error::DirNameCollision { @@ -1627,9 +1618,8 @@ mod tests { } } - // A file's in-flight `.partial` path shares the host namespace with - // another planned file's FINAL path (disc carries both `X` and - // `X.partial`) — see docs/extract.md for why this must be an error. + // A file's in-flight `.partial` path shares the host namespace with another planned file's + // FINAL path (disc carries both `X` and `X.partial`). #[test] fn a_files_partial_path_colliding_with_another_files_final_name_is_an_error() { let root = DirSpec { @@ -1707,9 +1697,8 @@ mod tests { assert!(!is_title_vob("VTS_01_1.IFO")); } - // Regression (rc.6 audit, finding #449): a MULTI-EXTENT AACS file must - // re-anchor the unit-alignment base PER extent, not once at the first — - // see docs/extract.md for the alignment arithmetic this guards against. + // Regression (rc.6 audit, finding #449): a MULTI-EXTENT AACS file must re-anchor the + // unit-alignment base PER extent, not once at the first. #[test] fn multi_extent_aacs_anchors_unit_base_per_extent() { const SECTORS_EACH: u32 = 3; // one AACS unit per extent @@ -1774,9 +1763,8 @@ mod tests { ); } - // An ECMA-167 4/14.14.1.1 type-1 extent is ALLOCATED BUT NOT RECORDED and - // must be zero-filled, not read from media — see docs/extract.md. The - // hole is filled with a non-zero pattern so the two paths differ by CONTENT. + // An ECMA-167 4/14.14.1.1 type-1 extent is ALLOCATED BUT NOT RECORDED and must be + // zero-filled, not read from media. #[test] fn extract_tree_zero_fills_an_unrecorded_extent_instead_of_reading_it() { const SECTORS_EACH: u32 = 1; @@ -1823,8 +1811,7 @@ mod tests { } // Focused alignment-computation check underpinning the per-extent fix - // (`aacs::content::is_unit_aligned`'s exact arithmetic) — see - // docs/extract.md for the full per-extent-vs-first-extent-base reasoning. + // (`aacs::content::is_unit_aligned`'s exact arithmetic). #[test] fn per_extent_base_is_aligned_first_extent_base_is_not() { use crate::aacs::content::is_unit_aligned; @@ -1940,9 +1927,8 @@ mod tests { assert!(matches!(err, Error::DirInsufficientSpace { .. })); } - // Regression: the per-VTS key crack in `resolve_vts_key` must gather ONLY - // this VTS's own title-VOB extents, not a sibling VTS's — see - // docs/extract.md for the group-filter loosening this guards against. + // Regression: the per-VTS key crack in `resolve_vts_key` must gather ONLY this VTS's own + // title-VOB extents, not a sibling VTS's. #[test] fn css_two_vts_groups_do_not_cross_contaminate_keys() { fn scrambled_vob(title_key: [u8; 5], marker: u8) -> (Vec<u8>, Vec<u8>) { @@ -2017,9 +2003,8 @@ mod tests { assert!(res.complete); } - // A VTS that IS scrambled but whose key could not be recovered must FAIL, - // not borrow another VTS's key (`CrackOutcome`, not `Option`) — see - // docs/extract.md. VTS_01 here is crackable, VTS_02 genuinely fails. + // A VTS that IS scrambled but whose key could not be recovered must FAIL, not borrow + // another VTS's key (`CrackOutcome`, not `Option`). #[test] fn a_scrambled_vts_that_cannot_be_cracked_fails_instead_of_borrowing_a_key() { let key_1 = [0x10u8, 0x20, 0x30, 0x40, 0x50]; @@ -2138,9 +2123,9 @@ mod tests { ); } - // Regression: within ONE extent, "sectors remaining IN THIS EXTENT" must - // be `sectors - sector_off`, not `sectors + sector_off` — the latter lets - // a later batch read PAST the extent into unrelated content. See docs/extract.md. + // Regression: within ONE extent, "sectors remaining IN THIS EXTENT" must be `sectors - + // sector_off`, not `sectors + sector_off` — the latter lets a later batch read PAST the + // extent into unrelated content. #[test] fn extent_second_batch_stays_within_its_own_bounds() { // Extent A: 1600 sectors of pattern 'A' -- just over diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index 3fbe2ef2..2e17e0c4 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -1,11 +1,9 @@ -//! HD-DVD title scanning — `HVDVD_TS/` Enhanced-VOB (`.evo`) enumeration, a -//! **tree-level peer** of DVD/Blu-ray with its own scanner (this file), a -//! peer to [`Disc::scan_bluray_titles`]. Title composition is authoritative, -//! from the `ADV_OBJ/VPLST000.XPL` Advanced-Content playlist (parsed with -//! `roxmltree` into one [`DiscTitle`] per `<Title>`, clips resolved via -//! `.MAP` sidecars, container [`ContentFormat::MpegPs`]); falls back to the -//! `HVA*.VTI` clip-name heuristic when no playlist parses. See docs/hddvd.md -//! for the full title-composition/fallback/known-gaps writeup. +//! HD-DVD title scanning — `HVDVD_TS/` Enhanced-VOB (`.evo`) enumeration, a **tree-level peer** +//! of DVD/Blu-ray with its own scanner (this file), a peer to [`Disc::scan_bluray_titles`]. +//! Title composition is authoritative, from the `ADV_OBJ/VPLST000.XPL` Advanced-Content +//! playlist (parsed with `roxmltree` into one [`DiscTitle`] per `<Title>`, clips resolved via +//! `.MAP` sidecars, container [`ContentFormat::MpegPs`]); falls back to the `HVA*.VTI` +//! clip-name heuristic when no playlist parses. use super::*; use crate::mux::ps::{PsDemuxer, dvd_audio_pid}; @@ -43,9 +41,8 @@ const VTI_CLIP_ENTRY_STRIDE: usize = 0x140; /// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory. const MAX_VTI_HITS: usize = 8192; -// Parses the ADVANCED-VTS VTI clip-name table (authored order): collects every -// NUL-terminated `*.EVO` name and keeps the largest group sharing one residue -// mod the stride (the clip table). See docs/hddvd.md#vti-parsing. +// Parses the ADVANCED-VTS VTI clip-name table (authored order): collects every NUL-terminated +// `*.EVO` name and keeps the largest group sharing one residue mod the stride (the clip table). fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> { if !vti.starts_with(HDDVD_VTI_MAGIC) { return Vec::new(); @@ -97,9 +94,8 @@ fn is_feature_clip(name: &str) -> bool { base.to_ascii_lowercase().starts_with("feature") } -// Sniffs a video codec from an MPEG-PS video ES sample by start code: MPEG-2 -// (`B3`), VC-1 (`0F`), or H.264 (inferred from an SPS NAL, type 7). -// See docs/hddvd.md#codec-sniffing. +// Sniffs a video codec from an MPEG-PS video ES sample by start code: MPEG-2 (`B3`), VC-1 +// (`0F`), or H.264 (inferred from an SPS NAL, type 7). fn sniff_video_codec(es: &[u8]) -> Option<Codec> { let mut saw_h264_sps = false; let mut i = 0usize; @@ -133,9 +129,9 @@ fn sniff_audio_codec(es: &[u8]) -> Option<Codec> { has_sync.then_some(Codec::Ac3Plus) } -// Demuxes an `.evo` clip head into one Stream per elementary stream (video + -// DD+ audio), codec sniffed from the ES bytes. Empty vec on unreadable/no -// stream; `Err` only on a cancelled probe. See docs/hddvd.md#probe-evo-streams. +// Demuxes an `.evo` clip head into one Stream per elementary stream (video + DD+ audio), codec +// sniffed from the ES bytes. Empty vec on unreadable/no stream; `Err` only on a cancelled +// probe. fn probe_evo_streams( reader: &mut dyn SectorSource, extents: &[Extent], @@ -239,9 +235,8 @@ fn collect_es( ) { use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX}; const EXTENDED_STREAM_ID: u8 = 0xFD; - // VC-1 rides extended-stream-id `0xFD` ext `0x55`; other `0xFD` exts are - // HD audio (MLP/TrueHD, routing deferred), so only this ext is video. - // See docs/hddvd.md#collect-es. + // VC-1 rides extended-stream-id `0xFD` ext `0x55`; other `0xFD` exts are HD audio + // (MLP/TrueHD, routing deferred), so only this ext is video. const VC1_STREAM_ID_EXT: u8 = 0x55; // Whether this packet is the VC-1 video sub-stream of the 0xFD extended id. let is_vc1_ext = @@ -341,23 +336,18 @@ fn evo_from_src(src: &str) -> Option<String> { // 5x margin for extra wrapper layers while staying trivially stack-safe. const MAX_XPL_DEPTH: usize = 32; -// Amplification bounds: cap declared titles/clips/chapters against a crafted -// playlist turning a small file into huge probe/memory cost. -// See docs/hddvd.md#xpl-amplification-bounds for the full writeup. +// Amplification bounds: cap declared titles/clips/chapters against a crafted playlist turning a +// small file into huge probe/memory cost. const MAX_XPL_TITLES: usize = 512; -// See docs/hddvd.md#xpl-amplification-bounds. const MAX_HDDVD_CLIPS: usize = 512; -// See docs/hddvd.md#xpl-amplification-bounds. const MAX_XPL_CLIPS_PER_TITLE: usize = 256; -// See docs/hddvd.md#xpl-amplification-bounds. const MAX_XPL_CHAPTERS_PER_TITLE: usize = 1024; -// Memoized `probe_evo_streams`, keyed on the resolved extent list so repeat -// probes of the same physical clip (via different names/titles) are one -// read. See docs/hddvd.md#evoprobecache. +// Memoized `probe_evo_streams`, keyed on the resolved extent list so repeat probes of the same +// physical clip (via different names/titles) are one read. #[derive(Default)] struct EvoProbeCache { seen: std::collections::HashMap<Vec<(u32, u32)>, Vec<Stream>>, @@ -388,9 +378,9 @@ impl EvoProbeCache { } } -// Rejects XML whose nesting exceeds MAX_XPL_DEPTH before it reaches the -// recursive-descent parser (unbounded nesting can stack-overflow-abort a -// process, which no Err/catch_unwind can contain). See docs/hddvd.md#xpl_depth_within_limit. +// Rejects XML whose nesting exceeds MAX_XPL_DEPTH before it reaches the recursive-descent +// parser (unbounded nesting can stack-overflow-abort a process, which no Err/catch_unwind can +// contain). fn xpl_depth_within_limit(text: &str) -> bool { let b = text.as_bytes(); let mut i = 0usize; @@ -644,9 +634,9 @@ fn compose_xpl_titles( } impl Disc { - // Scans HD-DVD titles from HVDVD_TS/ .evo clips, joining feature clips via - // the VTI (or one title per clip if unparseable). Halted is the only Err - // this returns (cancellation), everything else best-effort. See docs/hddvd.md#scan_hddvd_titles. + // Scans HD-DVD titles from HVDVD_TS/.evo clips, joining feature clips via the VTI (or one + // title per clip if unparseable). Halted is the only Err this returns (cancellation), + // everything else best-effort. pub(super) fn scan_hddvd_titles( reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, @@ -1059,9 +1049,8 @@ mod tests { assert!(!is_feature_clip("EPK.EVO")); } - // The SECOND size accumulator (feature-part composition, not the XPL path) - // must also saturate a disc-declared size near u64::MAX rather than - // overflow. See docs/hddvd.md#test-rationale. + // The SECOND size accumulator (feature-part composition, not the XPL path) must also + // saturate a disc-declared size near u64::MAX rather than overflow. #[test] fn scan_hddvd_feature_composition_saturates_absurd_disc_declared_sizes() { let mut disc = MemDisc::new(); @@ -1153,9 +1142,8 @@ mod tests { assert!(titles.iter().any(|t| t.playlist == "TRAILER.EVO")); } - // A split feature with one part carrying an unrecorded extent must not be - // composed into a FEATURE title (would silently play short); other clips - // stay available on their own. See docs/hddvd.md#test-rationale. + // A split feature with one part carrying an unrecorded extent must not be composed into a + // FEATURE title (would silently play short); other clips stay available on their own. #[test] fn scan_hddvd_does_not_compose_a_feature_over_an_unrecorded_part() { let mut disc = MemDisc::new(); @@ -1215,9 +1203,8 @@ mod tests { ); } - // The refusal above must also be ACCOUNTED, logging the unrecorded - // extent's own E_UDF_UNRECORDED_EXTENT code (not a hardcoded/neighbouring - // literal). See docs/hddvd.md#test-rationale. + // The refusal above must also be ACCOUNTED, logging the unrecorded extent's own + // E_UDF_UNRECORDED_EXTENT code (not a hardcoded/neighbouring literal). #[test] fn scan_hddvd_logs_an_unrecorded_feature_part_with_its_own_code() { let mut disc = MemDisc::new(); @@ -1269,9 +1256,8 @@ mod tests { ); } - // The Ok-but-empty twin of the test above: a feature part whose - // file_extents SUCCEEDS but yields nothing usable must also refuse the - // composition and log it. See docs/hddvd.md#test-rationale. + // The Ok-but-empty twin of the test above: a feature part whose file_extents SUCCEEDS but + // yields nothing usable must also refuse the composition and log it. #[test] fn scan_hddvd_does_not_compose_a_feature_over_a_part_with_no_usable_extent() { let mut disc = MemDisc::new(); @@ -1772,9 +1758,8 @@ mod tests { assert!(parse_xpl_titles(b"<Playlist></Playlist>").is_empty()); } - // A playlist declaring far more titles than any real disc must be capped - // at MAX_XPL_TITLES before probe_evo_streams runs per title (drive-time - // amplification). See docs/hddvd.md#test-rationale. + // A playlist declaring far more titles than any real disc must be capped at MAX_XPL_TITLES + // before probe_evo_streams runs per title (drive-time amplification). #[test] fn parse_xpl_titles_caps_a_playlist_declaring_absurdly_many_titles() { const DECLARED: usize = MAX_XPL_TITLES + 500; @@ -1829,9 +1814,8 @@ mod tests { xpl } - // CLIPS PER TITLE: neither the title cap nor the depth cap bounds how many - // clip elements ONE title collects, and descendants() means nested - // ancestors multiply the count. See docs/hddvd.md#test-rationale. + // CLIPS PER TITLE: neither the title cap nor the depth cap bounds how many clip elements + // ONE title collects, and descendants() means nested ancestors multiply the count. #[test] fn parse_xpl_titles_caps_clips_per_title_against_descendant_amplification() { const NESTING: usize = 25; @@ -1865,9 +1849,8 @@ mod tests { ); } - // CHAPTERS PER TITLE: the same descendants() amplification, on the second - // unbounded collect() in the same loop, bounded separately from clips. - // See docs/hddvd.md#test-rationale. + // CHAPTERS PER TITLE: the same descendants() amplification, on the second unbounded + // collect() in the same loop, bounded separately from clips. #[test] fn parse_xpl_titles_caps_chapters_per_title() { const NESTING: usize = 25; @@ -1888,9 +1871,8 @@ mod tests { } } - // The control: a realistic multi-clip title still resolves EVERY clip and - // chapter (losing either to the cap would cost a genuine disc half its - // feature). See docs/hddvd.md#test-rationale. + // The control: a realistic multi-clip title still resolves EVERY clip and chapter (losing + // either to the cap would cost a genuine disc half its feature). #[test] fn parse_xpl_titles_keeps_every_clip_of_a_realistic_title() { let titles = parse_xpl_titles(SYNTH_XPL.as_bytes()); @@ -1927,9 +1909,8 @@ mod tests { ); } - // The depth guard must not reject real discs: adds self-closing tags, a - // comment, and a processing instruction the pre-parse scanner must not - // miscount as nesting. See docs/hddvd.md#test-rationale. + // The depth guard must not reject real discs: adds self-closing tags, a comment, and a + // processing instruction the pre-parse scanner must not miscount as nesting. #[test] fn parse_xpl_titles_accepts_real_world_nesting_depth() { let titles = parse_xpl_titles(SYNTH_XPL.as_bytes()); @@ -2387,9 +2368,8 @@ mod tests { ); } - // size_bytes sums DISC-DECLARED clip sizes, uncross-checked against real - // extents, so two clips near u64::MAX must saturate, not panic/wrap. - // See docs/hddvd.md#test-rationale. + // size_bytes sums DISC-DECLARED clip sizes, uncross-checked against real extents, so two + // clips near u64::MAX must saturate, not panic/wrap. #[test] fn compose_xpl_titles_saturates_absurd_disc_declared_clip_sizes() { let clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = [ @@ -2453,9 +2433,9 @@ mod tests { ); } - // A crafted playlist can name the SAME .evo many times over; each repeat - // must NOT push another copy of its extents onto the title (dedup keyed - // on .evo, mirroring bluray.rs's seen_clips gate). See docs/hddvd.md#test-rationale. + // A crafted playlist can name the SAME.evo many times over; each repeat must NOT push + // another copy of its extents onto the title (dedup keyed on.evo, mirroring bluray.rs's + // seen_clips gate). #[test] fn compose_xpl_titles_dedups_repeated_clip_references_by_evo() { let clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = [( @@ -2639,9 +2619,8 @@ mod tests { ); } - // A clip whose extents cannot be resolved must drop the SPLIT FEATURE - // (not compose it from the remaining part as if whole). - // See docs/hddvd.md#test-rationale. + // A clip whose extents cannot be resolved must drop the SPLIT FEATURE (not compose it from + // the remaining part as if whole). #[test] fn scan_hddvd_titles_drops_a_split_feature_whose_part_cannot_be_read() { let mut disc = MemDisc::new(); @@ -2704,9 +2683,9 @@ mod tests { ); } - // A zero-byte clip (ICB AD data_len == 0, the UDF AD-list terminator) - // must not produce a title. Exercises the upstream file_extents - // terminator path, not the scan's own zero-sector guard. See docs/hddvd.md#test-rationale. + // A zero-byte clip (ICB AD data_len == 0, the UDF AD-list terminator) must not produce a + // title. Exercises the upstream file_extents terminator path, not the scan's own + // zero-sector guard. #[test] fn scan_hddvd_titles_excludes_a_clip_with_zero_sectors() { let mut disc = MemDisc::new(); @@ -2907,9 +2886,9 @@ mod tests { lay_dir(disc, &root); } - // THE THIRD AMPLIFICATION AXIS: the clip-name FALLBACK (reached by - // omitting /ADV_OBJ) emits one title per directory entry; many entries - // resolving to the SAME extents must cost ONE probe. See docs/hddvd.md#test-rationale. + // THE THIRD AMPLIFICATION AXIS: the clip-name FALLBACK (reached by omitting /ADV_OBJ) emits + // one title per directory entry; many entries resolving to the SAME extents must cost ONE + // probe. #[test] fn scan_hddvd_titles_probes_once_for_entries_resolving_to_identical_extents() { const ENTRIES: usize = 512; @@ -2952,9 +2931,8 @@ mod tests { assert!(!titles.is_empty(), "the clips still enumerate"); } - // The clip cap and the per-probe read budget are ONE bound, not two: only - // their PRODUCT (worst-case drive time) is meaningful, capped at 8 GiB. - // See docs/hddvd.md#test-rationale. + // The clip cap and the per-probe read budget are ONE bound, not two: only their PRODUCT + // (worst-case drive time) is meaningful, capped at 8 GiB. #[test] fn the_clip_cap_and_probe_budget_bound_a_scans_worst_case_read_volume() { const CEILING_BYTES: u64 = 8 * 1024 * 1024 * 1024; @@ -2978,8 +2956,8 @@ mod tests { } } - // CONTROL: memoization must not silently collapse DISTINCT clips — each - // title must keep the streams of ITS OWN clip. See docs/hddvd.md#test-rationale. + // CONTROL: memoization must not silently collapse DISTINCT clips — each title must keep the + // streams of ITS OWN clip. #[test] fn scan_hddvd_titles_still_probes_each_distinct_clip() { let mut disc = MemDisc::new(); @@ -3039,9 +3017,8 @@ mod tests { ); } - // Memoization alone is NOT the whole fix: distinct extent lists (each a - // 1-sector File Entry) all miss the memo, so MAX_HDDVD_CLIPS is what - // bounds that. See docs/hddvd.md#test-rationale. + // Memoization alone is NOT the whole fix: distinct extent lists (each a 1-sector File + // Entry) all miss the memo, so MAX_HDDVD_CLIPS is what bounds that. #[test] fn scan_hddvd_titles_caps_a_directory_declaring_absurdly_many_clips() { const ENTRIES: usize = MAX_HDDVD_CLIPS + 300; diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 24553198..2d980076 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -485,16 +485,14 @@ pub struct Extent { pub sector_count: u32, } -// THE ONE definition of "structurally AACS-encrypted" — shared by the fast -// identify and the full scan so they can't silently desync. Structural, not -// cryptographic. See docs/disc-mod.md — aacs_dir_present. +// THE ONE definition of "structurally AACS-encrypted" — shared by the fast identify and the +// full scan so they can't silently desync. Structural, not cryptographic. pub(crate) fn aacs_dir_present(udf_fs: &crate::udf::UdfFs) -> bool { udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some() } -// Title-ranking heuristic thresholds. Each gates a DISTINCT decision — several -// share a value (0.5) by coincidence, so they are named and tuned separately. -// See docs/disc-mod.md for the shape gate. +// Title-ranking heuristic thresholds. Each gates a DISTINCT decision — several share a value +// (0.5) by coincidence, so they are named and tuned separately. /// Nav candidate: a real-video title qualifies only if it runs at least this /// fraction of the longest probable-video title's duration. const NAV_CANDIDATE_MIN_DURATION_FRAC: f64 = 0.5; @@ -523,14 +521,13 @@ const MIN_FEATURE_CHAPTERS: usize = 2; /// chapter table from a couple of clustered marks. const CHAPTER_SPAN_MIN_FRAC: f64 = 0.5; -/// Issue #45: does `q` look like a COMPLETE feature presentation — a real chapter -/// table (≥ [`MIN_FEATURE_CHAPTERS`] marks) spanning ≥ [`CHAPTER_SPAN_MIN_FRAC`] -/// of its runtime? A bare body subset (Alita's 00703 = one mark) fails; a genuine -/// feature (Alita's 00800 = 37 marks) passes. Gates the composite bumper limb so a -/// chaptered feature is not demoted below its own un-chaptered body subset. This is -/// the ABSOLUTE-threshold form; to switch to the RELATIVE form (only if a hoard -/// sweep finds an SM3-style decoy whose chaptered member is the SUPERSET) replace -/// the call site with `p.chapters.len() <= q.chapters.len()`. See docs/disc-mod.md. +/// Issue #45: does `q` look like a COMPLETE feature presentation — a real chapter table (≥ +/// [`MIN_FEATURE_CHAPTERS`] marks) spanning ≥ [`CHAPTER_SPAN_MIN_FRAC`] of its runtime? A bare +/// body subset (Alita's 00703 = one mark) fails; a genuine feature (Alita's 00800 = 37 marks) +/// passes. Gates the composite bumper limb so a chaptered feature is not demoted below its own +/// un-chaptered body subset. This is the ABSOLUTE-threshold form; to switch to the RELATIVE +/// form (only if a hoard sweep finds an SM3-style decoy whose chaptered member is the SUPERSET) +/// replace the call site with `p.chapters.len() <= q.chapters.len()`. fn is_complete_feature_presentation(q: &DiscTitle) -> bool { q.chapters.len() >= MIN_FEATURE_CHAPTERS && q.duration_secs > 0.0 @@ -548,9 +545,8 @@ fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u3 crate::udf::merge_ranges(&ranges) } -// Corrects a title's TrueHD channels/sample-rate/Atmos by probing the first -// decrypted major sync (`reader` must yield DECRYPTED sectors: mux time, not -// scan). See docs/disc-mod.md — correct_truehd_channels. +// Corrects a title's TrueHD channels/sample-rate/Atmos by probing the first decrypted major +// sync (`reader` must yield DECRYPTED sectors: mux time, not scan). pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) { use crate::mux::codec::truehd::{ truehd_channels, truehd_sample_rate_hz, truehd_sync_info_from_stream, @@ -648,9 +644,9 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut } } -// Merges per-title AACS key ranges into the sorted, disjoint set the whole-disc -// map needs. Same-key overlaps are UNIONED (never drop coverage); a malformed -// different-key overlap is dropped to stay disjoint. See docs/disc-mod.md. +// Merges per-title AACS key ranges into the sorted, disjoint set the whole-disc map needs. +// Same-key overlaps are UNIONED (never drop coverage); a malformed different-key overlap is +// dropped to stay disjoint. fn merge_content_key_ranges( mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>, ) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> { @@ -956,7 +952,6 @@ impl Resolution { } } - // See docs/disc-mod.md — Resolution::pixels for the fabrication-trap history. /// Pixel dimensions (width, height). `None` when the resolution is /// [`Resolution::Unknown`] — "no dimensions", not a guess. SD frames are /// the DVD-Video coded pictures (BT.601 525/60, 625/50 active area); HD @@ -1446,7 +1441,6 @@ impl DiscTitle { self.video_streams().next().is_some() } - // See docs/disc-mod.md — has_probable_video for the full audit-R6 rationale. /// Whether this title PLAUSIBLY carries feature video content — a real /// primary video stream, OR a size/duration profile only video explains /// (at least 2 GB and 5 minutes). Deliberately MORE permissive than @@ -1826,9 +1820,8 @@ impl Disc { session.set_bus_content_ranges(std::sync::Arc::from(content_ranges.into_boxed_slice())); } - // The extents an image-time CSS crack scans, in the crate's CANONICAL - // order (main feature's own extents, playback order) — do not re-derive - // this inline. See docs/disc-mod.md — image_crack_extents. + // The extents an image-time CSS crack scans, in the crate's CANONICAL order (main feature's + // own extents, playback order) — do not re-derive this inline. fn image_crack_extents(titles: &[DiscTitle]) -> &[Extent] { // Prefer the first title with BOTH video and extents (the main feature) // so the CSS crack scans the movie, not a streamless decoy. Falls back @@ -1883,9 +1876,8 @@ impl Disc { Ok(disc) } - // Reads a disc's AACS key-input files: (Unit_Key_RO.inf, MKB) raw bytes. - // Shared body for read_aacs_inputs (ISO) / read_aacs_inputs_from_drive - // (live drive). See docs/disc-mod.md — read_aacs_inputs_from_reader. + // Reads a disc's AACS key-input files: (Unit_Key_RO.inf, MKB) raw bytes. Shared body for + // read_aacs_inputs (ISO) / read_aacs_inputs_from_drive (live drive). pub(crate) fn read_aacs_inputs_from_reader( reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, @@ -1899,9 +1891,8 @@ impl Disc { Ok((inf, mkb, version)) } - // AACS major version from the content certificate; drives the - // Unit_Key_RO.inf parse stride. Defaults to UHD (V20, 64-byte stride) - // when unreadable. See docs/disc-mod.md — read_aacs_version. + // AACS major version from the content certificate; drives the Unit_Key_RO.inf parse stride. + // Defaults to UHD (V20, 64-byte stride) when unreadable. fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 { match crate::aacs::read_first( &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::ContentCert), @@ -1924,9 +1915,9 @@ impl Disc { } } - // Reads the AACS MKB's real record stream — NOT its ~128 MiB zero padding. - // Reads a bounded, growing prefix instead, avoiding both the padding read - // and the read_file MAX_FILE_BYTES cap. See docs/disc-mod.md. + // Reads the AACS MKB's real record stream — NOT its ~128 MiB zero padding. Reads a bounded, + // growing prefix instead, avoiding both the padding read and the read_file MAX_FILE_BYTES + // cap. fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> { const START_BYTES: usize = 16 * 1024 * 1024; const MAX_BYTES: usize = 64 * 1024 * 1024; @@ -2200,14 +2191,12 @@ impl Disc { // ── Internal helpers ──────────────────────────────────────────────────── - /// Total ordering used to sort `Disc::titles` so `titles[0]` is the - /// canonical main feature. Sort priority: (1) real titles (`size_bytes - /// <= capacity_bytes`, skipped when capacity is UNKNOWN i.e. 0) before - /// virtual "play-all" composites; (2) among real titles, LARGEST - /// physical size first; (3) tiebreak on longer duration. Not a plain - /// duration sort: a branching UHD's play-all can report an inflated - /// duration/size exceeding the disc's physical capacity. These are the - /// [`Self::canonical_title_order`] sort keys; see docs/disc-mod.md. + /// Total ordering used to sort `Disc::titles` so `titles[0]` is the canonical main feature. + /// Sort priority: (1) real titles (`size_bytes <= capacity_bytes`, skipped when capacity is + /// UNKNOWN i.e. 0) before virtual "play-all" composites; (2) among real titles, LARGEST + /// physical size first; (3) tiebreak on longer duration. Not a plain duration sort: a + /// branching UHD's play-all can report an inflated duration/size exceeding the disc's + /// physical capacity. These are the [`Self::canonical_title_order`] sort keys. pub const CANONICAL_TITLE_ORDER_KEYS: &'static [&'static str] = &[ "fits-disc", "largest-size", @@ -2217,9 +2206,8 @@ impl Disc { "more-subs", ]; - // Content-based forced-subtitle detection, restricted to `BdTs` titles — - // gates on STREAM CONTAINER not disc-tree format (only BdTs carries - // PES-wrapped PGS). See docs/disc-mod.md for why it's its own function. + // Content-based forced-subtitle detection, restricted to `BdTs` titles — gates on STREAM + // CONTAINER not disc-tree format (only BdTs carries PES-wrapped PGS). fn probe_forced_subtitles_for_bdts_titles( reader: &mut dyn SectorSource, titles: &mut [DiscTitle], @@ -2273,13 +2261,11 @@ impl Disc { } /// The keys [`Self::main_feature_order`] layers ON TOP of - /// [`Self::CANONICAL_TITLE_ORDER_KEYS`], highest priority first, as - /// diagnostic tokens: `nav-feature` (disc's own HDMV nav pick, - /// authoritative), `authoring-feature` (disc's menu-designated feature), - /// `standalone` (demotes a play-all/wrapper COMPOSITE below the real - /// title it wraps), `has-video` (demotes titles with no plausible video, - /// see [`DiscTitle::has_probable_video`]). Kept beside the comparator; - /// see docs/disc-mod.md for detail. + /// [`Self::CANONICAL_TITLE_ORDER_KEYS`], highest priority first, as diagnostic tokens: + /// `nav-feature` (disc's own HDMV nav pick, authoritative), `authoring-feature` (disc's + /// menu-designated feature), `standalone` (demotes a play-all/wrapper COMPOSITE below the + /// real title it wraps), `has-video` (demotes titles with no plausible video, see + /// [`DiscTitle::has_probable_video`]). Kept beside the comparator. pub const MAIN_FEATURE_ORDER_KEYS: &'static [&'static str] = &[ "nav-feature", "authoring-feature", @@ -2287,14 +2273,12 @@ impl Disc { "has-video", ]; - /// Precompute the [`TitleRank`] for every title, in the same index - /// order. Composite detection: title `P` is a composite when some OTHER - /// title `Q` has a non-empty clip-id set that is a PROPER SUBSET of `P`'s, - /// `Q` has plausible video, `Q` accounts for at least half of `P`'s - /// declared size, AND `P` has a WRAPPER shape (issue #45): either `Q` is a - /// much shorter cut or `Q` is nearly `P`'s size-equal. Capacity-independent - /// (`capacity` may be `0`/unknown). See docs/disc-mod.md for the shape - /// gate, its thresholds, and the known residual. + /// Precompute the [`TitleRank`] for every title, in the same index order. Composite + /// detection: title `P` is a composite when some OTHER title `Q` has a non-empty clip-id + /// set that is a PROPER SUBSET of `P`'s, `Q` has plausible video, `Q` accounts for at least + /// half of `P`'s declared size, AND `P` has a WRAPPER shape (issue #45): either `Q` is a + /// much shorter cut or `Q` is nearly `P`'s size-equal. Capacity-independent (`capacity` may + /// be `0`/unknown). pub fn rank_titles( titles: &[DiscTitle], hint: Option<&crate::labels::FeaturePlaylistHint>, @@ -2592,9 +2576,8 @@ impl std::fmt::Debug for Key { } } -// True if `unit_keys` covers EVERY supplied scrambled content `sample` — -// validation gate for Disc::decrypt_with. `false` if a set only covers some -// CPS units. See docs/disc-mod.md for why "first sample decrypts" is unsafe. +// True if `unit_keys` covers EVERY supplied scrambled content `sample` — validation gate for +// Disc::decrypt_with. `false` if a set only covers some CPS units. fn aligned_unit_keys_validate( unit_keys: &[(u32, [u8; 16])], samples: &[Vec<u8>], @@ -2747,12 +2730,11 @@ impl Disc { /// `did-work` reports `AACS: no`, and a *stock* drive on the cert route /// instead reports `LD: no, AACS: yes` — the real diagnostic. pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> { - // The firmware unlocker that ran (recorded on init): "freemkv", "LD", or - // "Renesas" — mutually exclusive per drive. See docs/disc-mod.md. + // The firmware unlocker that ran (recorded on init): "freemkv", "LD", or "Renesas" — + // mutually exclusive per drive. let prep = drive.unlocker_name(); - // Every firmware / vendor-CDB route (freemkv Raw Read, LD, Renesas/Pioneer - // vendor open) removes bus encryption AT THE DRIVE — clear content, no - // host cert. See docs/disc-mod.md — unlocker_matrix. + // Every firmware / vendor-CDB route (freemkv Raw Read, LD, Renesas/Pioneer vendor open) + // removes bus encryption AT THE DRIVE — clear content, no host cert. let fw_removed_bus = matches!(prep, Some("freemkv") | Some("LD") | Some("Renesas")); crate::unlock_bridge::unlocker_names() .into_iter() @@ -2779,14 +2761,12 @@ impl Disc { .collect() } - /// The system-wide decrypt correctness gate. Returns `Ok(())` when safe - /// to proceed with a copy or mux, and a clear typed error when - /// decryption is **needed but unavailable** — the case that would - /// otherwise write ciphertext or feed the demux undecryptable bytes and - /// exit 0. Every copy/mux entry point calls this after key resolution - /// and before any source-data processing, so the failure is pre-flight - /// (no partial output). `raw == true` always succeeds. See - /// docs/disc-mod.md for the full per-case verdict table. + /// The system-wide decrypt correctness gate. Returns `Ok(())` when safe to proceed with a + /// copy or mux, and a clear typed error when decryption is **needed but unavailable** — the + /// case that would otherwise write ciphertext or feed the demux undecryptable bytes and + /// exit 0. Every copy/mux entry point calls this after key resolution and before any + /// source-data processing, so the failure is pre-flight (no partial output). `raw == true` + /// always succeeds. pub fn ensure_decryptable(&self, raw: bool) -> Result<()> { self.ensure_decryptable_keys(raw, &self.decrypt_keys()) } @@ -2848,14 +2828,12 @@ impl Disc { Ok(()) } - /// Resolve decryption keys for muxing a *specific* title. For a **DVD** - /// the CSS title key MUST be recovered before descrambling: either reuse - /// the scan's cracked key if its span covers this title's VTS, or crack - /// it fresh from this title's own extents in natural PLAYBACK ORDER - /// (never largest-cell-first, the 1.5.1 bug). The crack path does NOT - /// gate on `self.css`, so a detection miss can never route the mux into - /// raw passthrough of scrambled sectors. Non-DVD schemes return - /// [`Self::decrypt_keys`] unchanged. See docs/disc-mod.md for detail. + /// Resolve decryption keys for muxing a *specific* title. For a **DVD** the CSS title key + /// MUST be recovered before descrambling: either reuse the scan's cracked key if its span + /// covers this title's VTS, or crack it fresh from this title's own extents in natural + /// PLAYBACK ORDER (never largest-cell-first, the 1.5.1 bug). The crack path does NOT gate + /// on `self.css`, so a detection miss can never route the mux into raw passthrough of + /// scrambled sectors. Non-DVD schemes return [`Self::decrypt_keys`] unchanged. pub fn decrypt_keys_for_title( &self, idx: usize, @@ -2949,9 +2927,8 @@ impl Disc { self.ensure_decryptable_keys(raw, keys) } - // Injects pre-resolved AACS unit keys into a scanned disc — the - // deferred-mux/resume path (keys from the mapfile's `# freemkv-uk:` - // header). See docs/disc-mod.md — inject_unit_keys. + // Injects pre-resolved AACS unit keys into a scanned disc — the deferred-mux/resume path + // (keys from the mapfile's `# freemkv-uk:` header). pub(crate) fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) { if let Some(aacs) = self.aacs.as_mut() { aacs.unit_keys = keys; @@ -3586,9 +3563,8 @@ mod tests { ); } - // scan_with must hand ScanOptions::halt to the BLU-RAY enumerator: other - // tests call scan_bluray_titles directly, so this wiring was otherwise - // uncovered. See docs/disc-mod.md for the fixture rationale. + // scan_with must hand ScanOptions::halt to the BLU-RAY enumerator: other tests call + // scan_bluray_titles directly, so this wiring was otherwise uncovered. #[test] fn scan_with_passes_the_halt_flag_to_the_bluray_enumerator() { use crate::udf::fixture::*; @@ -3681,9 +3657,9 @@ mod tests { ); } - // A Stop on a LIVE DRIVE never touches ScanOptions::halt (Drive fails - // every SCSI command with Error::Halted itself); the HD-DVD enumerator - // must not swallow that into a successful scan. See docs/disc-mod.md. + // A Stop on a LIVE DRIVE never touches ScanOptions::halt (Drive fails every SCSI command + // with Error::Halted itself); the HD-DVD enumerator must not swallow that into a successful + // scan. #[test] fn halted_reads_do_not_report_the_hddvd_scan_as_successful() { use crate::udf::fixture::PART_START; @@ -3732,9 +3708,8 @@ mod tests { ); } - // capacity_bytes = capacity * 2048 feeds canonical_title_order's oversize - // threshold; chosen so a `*` -> `+` mutation flips titles[0] (see - // docs/disc-mod.md), not merely a wrong numeric threshold. + // capacity_bytes = capacity * 2048 feeds canonical_title_order's oversize threshold; chosen + // so a `*` -> `+` mutation flips titles[0], not merely a wrong numeric threshold. #[test] fn scan_with_capacity_bytes_uses_multiplication_not_addition() { const CAPACITY_SECTORS: u32 = 3_997_952; // *2048 = 8_187_805_696; +2048 = 4_000_000 @@ -3762,7 +3737,6 @@ mod tests { } // Same mechanism as above, tuned to catch a `*` -> `/` mutation instead. - // See docs/disc-mod.md — scan_with_capacity_bytes tests. #[test] fn scan_with_capacity_bytes_uses_multiplication_not_division() { const CAPACITY_SECTORS: u32 = 204_800_000; // *2048 = huge; /2048 = 100_000 @@ -3790,8 +3764,8 @@ mod tests { } // ── Unknown must not fabricate a plausible value (finding 2) ──────────── - // Resolution::Unknown has no dimensions, so pixels() must report none — - // not (0, 0) or a plausible 1920x1080. See docs/disc-mod.md. + // Resolution::Unknown has no dimensions, so pixels() must report none — not (0, 0) or a + // plausible 1920x1080. #[test] fn unknown_resolution_reports_no_pixel_dimensions() { assert_eq!( @@ -4320,9 +4294,9 @@ mod tests { ); } - // read_mkb_content must GROW its bounded prefix when the real record - // stream runs longer. Run inside a watchdog: some growth-step mutants - // spin forever re-reading a zero-length prefix. See docs/disc-mod.md. + // read_mkb_content must GROW its bounded prefix when the real record stream runs longer. + // Run inside a watchdog: some growth-step mutants spin forever re-reading a zero-length + // prefix. #[test] fn read_mkb_content_grows_prefix_past_16mib_when_records_run_longer() { use crate::udf::fixture::*; @@ -5727,9 +5701,9 @@ mod tests { ); } - // THE defect: a key SOURCE that could not answer must not be reported as - // "this disc has no key" (E7022 for both used to send an operator - // hunting for a VUK through seven hours of 502s). See docs/disc-mod.md. + // THE defect: a key SOURCE that could not answer must not be reported as "this disc has no + // key" (E7022 for both used to send an operator hunting for a VUK through seven hours of + // 502s). #[test] fn key_source_failure_is_not_reported_as_a_missing_disc_key() { use crate::keysource::{KeySource, ResolveCtx, resolve_and_apply_traced}; @@ -6024,9 +5998,8 @@ mod tests { assert!(disc.ensure_decryptable(true).is_ok()); } - // The two CSS no-key conditions must classify oppositely: disc-wide - // (css_error set) is disc_level_no_key and NOT skippable; per-title - // stays skippable and NOT disc-level. See docs/disc-mod.md. + // The two CSS no-key conditions must classify oppositely: disc-wide (css_error set) is + // disc_level_no_key and NOT skippable; per-title stays skippable and NOT disc-level. #[test] fn css_disc_wide_no_key_is_disc_level_while_per_title_stays_skippable() { // Disc-wide: the scan saw scrambled sectors and recovered no key. @@ -7231,9 +7204,9 @@ mod tests { } } - // A multi-extent title where the target LBA lands in the SECOND extent — - // exercises both the first extent's boundary check and the running - // `cumulative` byte total added on the way past it. See docs/disc-mod.md. + // A multi-extent title where the target LBA lands in the SECOND extent — exercises both the + // first extent's boundary check and the running `cumulative` byte total added on the way + // past it. #[test] fn byte_offset_in_title_accumulates_across_extents() { let title = title_with_size( @@ -7962,9 +7935,8 @@ mod tests { assert_eq!(disc.aacs_disc_hash(), SHA1); } - // ── decrypt_keys_for_title: CSS crack-span reuse is half-open ───────── - // crack_span uses STRICT `<` both sides, so a title that merely ABUTS - // the span must NOT reuse the key. See docs/disc-mod.md. + // ── decrypt_keys_for_title: CSS crack-span reuse is half-open ───────── crack_span uses + // STRICT `<` both sides, so a title that merely ABUTS the span must NOT reuse the key. #[test] fn decrypt_keys_for_title_css_span_reuse_is_half_open() { const KEY: [u8; 5] = [0xA1, 0xB2, 0xC3, 0xD4, 0xE5]; diff --git a/src/disc/pgs_forced_probe.rs b/src/disc/pgs_forced_probe.rs index 5fcdf4ba..87bb0e4b 100644 --- a/src/disc/pgs_forced_probe.rs +++ b/src/disc/pgs_forced_probe.rs @@ -1,12 +1,11 @@ //! Content-based forced-subtitle detection for Blu-ray/UHD PGS tracks. //! -//! Gives `freemkv info` the muxer's own verdict up front, by reading the -//! title's PGS streams through the shared [`crate::mux::codec::pgs::ForcedTracker`] -//! classifier. A track is forced iff EVERY display set carries -//! `forced_on_flag`; the read budget ([`PROBE_BUDGET_SECTORS`]) is SPREAD -//! over each extent as sample windows ([`plan_windows`]), and content may -//! CLEAR (never set) a vendor flag behind [`crate::mux::codec::pgs::demotable`]. -//! [`StopReason`] narrows what a truncated read may assert (docs/pgs-forced-probe.md). +//! Gives `freemkv info` the muxer's own verdict up front, by reading the title's PGS streams +//! through the shared [`crate::mux::codec::pgs::ForcedTracker`] classifier. A track is forced +//! iff EVERY display set carries `forced_on_flag`; the read budget ([`PROBE_BUDGET_SECTORS`]) +//! is SPREAD over each extent as sample windows ([`plan_windows`]), and content may CLEAR +//! (never set) a vendor flag behind [`crate::mux::codec::pgs::demotable`]. [`StopReason`] +//! narrows what a truncated read may assert. use crate::disc::{Codec, DiscTitle, Stream}; use crate::mux::codec::CodecParser; @@ -16,9 +15,8 @@ use crate::sector::SectorSource; use std::collections::HashMap; const SECTOR_BYTES: usize = 2048; -// Read the clip in ~2 MiB chunks: a whole number of AACS aligned units -// (3 sectors / 6144 B; 1023 = 341 units). See docs/pgs-forced-probe.md -// (`CHUNK_SECTORS`) for why 1024 silently broke AACS reads past chunk 1. +// Read the clip in ~2 MiB chunks: a whole number of AACS aligned units (3 sectors / 6144 B; +// 1023 = 341 units). const CHUNK_SECTORS: u16 = 1023; // The alignment requirement above is enforced, not just described. @@ -27,32 +25,27 @@ const _: () = assert!( "probe chunks must be a whole number of AACS aligned units" ); -// Retries for a read that came back short of one AACS aligned unit before the -// run is declared truncated (`ReadFailed`, inconclusive). See -// docs/pgs-forced-probe.md (`STALL_RETRY_LIMIT`) for why this is small. +// Retries for a read that came back short of one AACS aligned unit before the run is declared +// truncated (`ReadFailed`, inconclusive). const STALL_RETRY_LIMIT: u32 = 2; -// Hard ceiling on sectors read per probe call (256 MiB) — the same total the -// old head-first design used, now SPREAD via `plan_windows` instead of spent -// on the title's first 27 seconds. See docs/pgs-forced-probe.md. +// Hard ceiling on sectors read per probe call (256 MiB) — the same total the old head-first +// design used, now SPREAD via `plan_windows` instead of spent on the title's first 27 seconds. const PROBE_BUDGET_SECTORS: u32 = 131_072; -// Display sets a SAMPLED run must see on a track before "all forced" may be -// asserted: one hit alone can wrongly promote a mostly-unflagged track. See -// docs/pgs-forced-probe.md (`PROMOTE_MIN_DISPLAY_SETS`). +// Display sets a SAMPLED run must see on a track before "all forced" may be asserted: one hit +// alone can wrongly promote a mostly-unflagged track. const PROMOTE_MIN_DISPLAY_SETS: u32 = 2; -// One sample window: ~32 MiB, a whole number of AACS aligned units -// (16_383 = 5461 units). Sized against measured subtitle density; see -// docs/pgs-forced-probe.md (`WINDOW_SECTORS`) for why not smaller/more. +// One sample window: ~32 MiB, a whole number of AACS aligned units (16_383 = 5461 units). Sized +// against measured subtitle density. const WINDOW_SECTORS: u32 = 16_383; -// Floor on a window (2 MiB): below this a window is too short to likely hit a -// display set. See docs/pgs-forced-probe.md (`MIN_WINDOW_SECTORS`). +// Floor on a window (2 MiB): below this a window is too short to likely hit a display set. const MIN_WINDOW_SECTORS: u32 = CHUNK_SECTORS as u32; -// Most windows spent on a single extent: past this, extra windows cost a seek -// each for no extra expected observations. See docs/pgs-forced-probe.md. +// Most windows spent on a single extent: past this, extra windows cost a seek each for no extra +// expected observations. const MAX_WINDOWS_PER_EXTENT: u32 = 8; // Windows must start (and, so that every chunk inside them does too, be sized) @@ -76,9 +69,8 @@ fn align_down(sectors: u32) -> u32 { sectors - sectors % crate::aacs::content::ALIGNED_UNIT_SECTORS } -// Where to read inside one extent, given the sector budget `share`: a pure -// function of `(sector_count, share)` so the per-extent memo is reproducible. -// See docs/pgs-forced-probe.md (`plan_windows`) for the asymmetric-predicate case. +// Where to read inside one extent, given the sector budget `share`: a pure function of +// `(sector_count, share)` so the per-extent memo is reproducible. fn plan_windows(sector_count: u32, share: u32) -> Vec<SampleWindow> { if sector_count == 0 { return Vec::new(); @@ -129,9 +121,9 @@ fn planned_coverage(sector_count: u32, share: u32) -> u32 { .fold(0u32, |acc, w| acc.saturating_add(w.len)) } -// What one probed extent showed about one PGS track — the monotone facts a -// `ForcedTracker` accumulates. Keeping evidence (not a composed verdict) is -// what makes per-extent memoisation sound; see docs/pgs-forced-probe.md. +// What one probed extent showed about one PGS track — the monotone facts a `ForcedTracker` +// accumulates. Keeping evidence (not a composed verdict) is what makes per-extent memoisation +// sound. #[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] pub(crate) struct TrackEvidence { /// A PGS display set was actually seen for this track in this extent. @@ -171,9 +163,8 @@ impl TrackEvidence { } } -// One extent's memoised evidence for one track, WITH the coverage it rests -// on — so a sampled read is never replayed as if the whole extent was read. -// See docs/pgs-forced-probe.md (`CachedEvidence`). +// One extent's memoised evidence for one track, WITH the coverage it rests on — so a sampled +// read is never replayed as if the whole extent was read. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) struct CachedEvidence { evidence: TrackEvidence, @@ -190,25 +181,24 @@ impl CachedEvidence { } } -// Memoises probe results across titles, keyed PER PHYSICAL EXTENT and per PGS -// track — `(start_lba, sector_count, pid)`, so playlists sharing clips without -// sharing extent LISTS still de-dupe. See docs/pgs-forced-probe.md. +// Memoises probe results across titles, keyed PER PHYSICAL EXTENT and per PGS track — +// `(start_lba, sector_count, pid)`, so playlists sharing clips without sharing extent LISTS +// still de-dupe. pub(crate) type ForcedProbeCache = HashMap<(u32, u32, u16), CachedEvidence>; -// Why the read loop stopped — decides whether observations may be applied as -// an authoritative verdict: "not forced" is positive evidence (sound however -// the loop stopped); "forced" is an absence claim (see docs/pgs-forced-probe.md). +// Why the read loop stopped — decides whether observations may be applied as an authoritative +// verdict: "not forced" is positive evidence (sound however the loop stopped); "forced" is an +// absence claim. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum StopReason { /// Every extent was read to its end, or every track had already settled as /// not-forced. The observation is as complete as it will ever get. Exhausted, - // `PROBE_BUDGET_SECTORS` was reached: a DESIGNED stop, not a failure — a - // forced track's display sets appear throughout the title, so a bounded - // prefix is representative. See docs/pgs-forced-probe.md. + // `PROBE_BUDGET_SECTORS` was reached: a DESIGNED stop, not a failure — a forced track's + // display sets appear throughout the title, so a bounded prefix is representative. Budget, - // Operator cancellation: bytes read were read correctly, but the cut-off - // is arbitrary, same as a read fault — see docs/pgs-forced-probe.md. + // Operator cancellation: bytes read were read correctly, but the cut-off is arbitrary, same + // as a read fault. Halted, /// A read error, or a short/zero-length read. The rest of the data was never /// seen; what was accumulated is an arbitrary prefix. Genuinely inconclusive. @@ -590,9 +580,8 @@ fn tracker_evidence(t: &ForcedTracker, sampled: bool) -> TrackEvidence { } } -// Compose the per-track verdicts a run is ENTITLED to assert from the -// evidence it gathered (four gates: observed, non_forced-on-truncation, -// demotable, PROMOTE_MIN_DISPLAY_SETS). See docs/pgs-forced-probe.md (`verdicts`). +// Compose the per-track verdicts a run is ENTITLED to assert from the evidence it gathered +// (four gates: observed, non_forced-on-truncation, demotable, PROMOTE_MIN_DISPLAY_SETS). fn verdicts(evidence: &HashMap<u16, TrackEvidence>, conclusive: bool) -> HashMap<u16, bool> { // Disc-level facts the demotion gate rests on, over the tracks judged // together: does the authoring house set the flag at all, and how busy is the @@ -824,9 +813,9 @@ mod tests { assert!(s.forced, "no content observed → vendor forced preserved"); } - // A reader that serves a fixed BD-TS byte stream once, then EOF, so the - // probe's demux→parse→observe→apply path runs on real synthetic PGS - // content. Sector-granular like every real `SectorSource`; see docs/pgs-forced-probe.md. + // A reader that serves a fixed BD-TS byte stream once, then EOF, so the probe's + // demux→parse→observe→apply path runs on real synthetic PGS content. Sector-granular like + // every real `SectorSource` struct TsReader { data: Vec<u8>, pos: usize, @@ -905,9 +894,8 @@ mod tests { pkt } - // Two BD-TS PES on `pid`, both carrying `es`: an open PES only completes - // when the next PES start arrives. The follower deliberately repeats `es` - // rather than a fixed filler — see docs/pgs-forced-probe.md (`ts_stream`). + // Two BD-TS PES on `pid`, both carrying `es`: an open PES only completes when the next PES + // start arrives. The follower deliberately repeats `es` rather than a fixed filler. fn ts_stream(pid: u16, es: &[u8]) -> Vec<u8> { let mut s = bd_pes_packet(pid, 0, es); s.extend_from_slice(&bd_pes_packet(pid, 1, es)); @@ -1146,9 +1134,8 @@ mod tests { assert_eq!(reader.served, 0, "no PGS PIDs → no reads"); } - // ── per-extent, per-track memoisation ─────────────────────────────────── - // MEASURED: overlapping-but-not-identical extent lists must not re-read - // shared clips. See docs/pgs-forced-probe.md (`overlapping_extent_lists_read_each_clip_once`). + // ── per-extent, per-track memoisation ─────────────────────────────────── MEASURED: + // overlapping-but-not-identical extent lists must not re-read shared clips. #[test] fn overlapping_extent_lists_read_each_clip_once() { let pid = 0x1200u16; @@ -1202,9 +1189,8 @@ mod tests { ); } - // A later playlist declaring MORE PGS tracks over the SAME extents must - // still probe the extra track, not silently keep its vendor flag. See - // docs/pgs-forced-probe.md (`extra_pgs_track_over_known_extents_is_still_probed`). + // A later playlist declaring MORE PGS tracks over the SAME extents must still probe the + // extra track, not silently keep its vendor flag. #[test] fn extra_pgs_track_over_known_extents_is_still_probed() { let ext = Extent { @@ -1269,9 +1255,8 @@ mod tests { } } - // Every probe read must begin on an AACS aligned-unit boundary measured - // from the extent's own base, or a decrypting source rejects it. See - // docs/pgs-forced-probe.md (`probe_reads_stay_on_aacs_unit_boundaries`). + // Every probe read must begin on an AACS aligned-unit boundary measured from the extent's + // own base, or a decrypting source rejects it. #[test] fn probe_reads_stay_on_aacs_unit_boundaries() { let pid = 0x1200u16; @@ -1329,9 +1314,8 @@ mod tests { } } - // A short-but-nonzero read must advance by what was READ, not requested, - // or a forced verdict gets asserted over sectors nobody read. See - // docs/pgs-forced-probe.md (`short_reads_do_not_skip_sectors`). + // A short-but-nonzero read must advance by what was READ, not requested, or a forced + // verdict gets asserted over sectors nobody read. #[test] fn short_reads_do_not_skip_sectors() { let pid = 0x1200u16; @@ -1364,9 +1348,8 @@ mod tests { ); } - // A short read must not break the aligned-unit invariant `CHUNK_SECTORS` - // exists to hold: `lba` must advance only by whole units. See - // docs/pgs-forced-probe.md (`short_reads_stay_on_aacs_unit_boundaries`). + // A short read must not break the aligned-unit invariant `CHUNK_SECTORS` exists to hold: + // `lba` must advance only by whole units. #[test] fn short_reads_stay_on_aacs_unit_boundaries() { let pid = 0x1200u16; @@ -1407,9 +1390,8 @@ mod tests { ); } - // A source that can never yield a whole aligned unit must not spin: the - // loop retries a bounded number of times, then stops inconclusively. See - // docs/pgs-forced-probe.md (`a_source_below_one_aligned_unit_stops_instead_of_spinning`). + // A source that can never yield a whole aligned unit must not spin: the loop retries a + // bounded number of times, then stops inconclusively. #[test] fn a_source_below_one_aligned_unit_stops_instead_of_spinning() { let pid = 0x1200u16; @@ -1440,9 +1422,8 @@ mod tests { assert!(cache.is_empty(), "inconclusive run is not memoised"); } - // ── mutation-triage additions ─────────────────────────────────────────── - // Mutation guard for `sub.codec == Codec::Pgs`: only PGS subtitle tracks - // are ever probed by content. See docs/pgs-forced-probe.md (`non_pgs_subtitle_codec_is_excluded_from_the_probe`). + // ── mutation-triage additions ─────────────────────────────────────────── Mutation guard + // for `sub.codec == Codec::Pgs`: only PGS subtitle tracks are ever probed by content. #[test] fn non_pgs_subtitle_codec_is_excluded_from_the_probe() { let mut reader = EndlessReader { served: 0 }; @@ -1466,9 +1447,8 @@ mod tests { ); } - // Mutation guard for `stalled > STALL_RETRY_LIMIT`: exactly the limit's - // worth of retries are allowed before giving up. See - // docs/pgs-forced-probe.md (`stalled_retries_stop_at_exactly_the_limit`). + // Mutation guard for `stalled > STALL_RETRY_LIMIT`: exactly the limit's worth of retries + // are allowed before giving up. #[test] fn stalled_retries_stop_at_exactly_the_limit() { let pid = 0x1200u16; @@ -1492,9 +1472,8 @@ mod tests { ); } - // Padding TS packets (sync byte only, PID 0, discarded by the demuxer) to - // push the real display set past a mutated `got + SECTOR_BYTES` offset. - // See docs/pgs-forced-probe.md (`filler_packets`). + // Padding TS packets (sync byte only, PID 0, discarded by the demuxer) to push the real + // display set past a mutated `got + SECTOR_BYTES` offset. fn filler_packets(count: usize) -> Vec<u8> { let mut v = vec![0u8; count * 192]; for i in 0..count { @@ -1503,9 +1482,8 @@ mod tests { v } - // Mutation guard for `got as usize * SECTOR_BYTES` (feed-length on a - // fully-served chunk): must hand the WHOLE chunk to the demuxer. See - // docs/pgs-forced-probe.md (`feed_uses_the_full_read_length_not_a_truncated_one`). + // Mutation guard for `got as usize * SECTOR_BYTES` (feed-length on a fully-served chunk): + // must hand the WHOLE chunk to the demuxer. #[test] fn feed_uses_the_full_read_length_not_a_truncated_one() { let pid = 0x1200u16; @@ -1558,9 +1536,8 @@ mod tests { } } - // Mutation guard for the `||` in the early-exit check: non-forced - // evidence CARRIED IN from a prior extent must stop reading a later one - // immediately. See docs/pgs-forced-probe.md (`carried_non_forced_evidence_stops_reading_a_content_free_extent`). + // Mutation guard for the `||` in the early-exit check: non-forced evidence CARRIED IN from + // a prior extent must stop reading a later one immediately. #[test] fn carried_non_forced_evidence_stops_reading_a_content_free_extent() { let pid = 0x1200u16; @@ -1618,9 +1595,8 @@ mod tests { ); } - // Synthetic fixtures reproduce the measured shape of real discs to test - // the probe's logic, not the shape itself. See docs/pgs-forced-probe.md - // for TrackShape's field semantics (alignment, period, count). + // Synthetic fixtures reproduce the measured shape of real discs to test the probe's logic, + // not the shape itself. #[derive(Clone, Copy)] struct TrackShape { pid: u16, @@ -1726,9 +1702,8 @@ mod tests { }) } - // Spec: a verdict that DEMOTES a track clears a `Forced` qualifier too — - // `forced` and `qualifier` are one fact for two consumers. See - // docs/pgs-forced-probe.md (`a_demoted_track_stops_calling_itself_forced`). + // Spec: a verdict that DEMOTES a track clears a `Forced` qualifier too — `forced` and + // `qualifier` are one fact for two consumers. #[test] fn a_demoted_track_stops_calling_itself_forced() { let mut title = pgs_title(0x1200, true); @@ -1747,9 +1722,7 @@ mod tests { ); } - // Spec: a qualifier that is not a forced claim (e.g. `Sdh`) is not the - // probe's to touch. See docs/pgs-forced-probe.md - // (`a_demoted_track_keeps_a_qualifier_that_is_not_a_forced_claim`). + // Spec: a qualifier that is not a forced claim (e.g. `Sdh`) is not the probe's to touch. #[test] fn a_demoted_track_keeps_a_qualifier_that_is_not_a_forced_claim() { let mut title = pgs_title(0x1200, true); @@ -1774,9 +1747,8 @@ mod tests { .expect("track present") } - // THE headline fix: a feature's subtitles begin well past the old - // head-first budget's reach. See docs/pgs-forced-probe.md - // (`subtitles_beyond_the_old_head_budget_are_observed`). + // THE headline fix: a feature's subtitles begin well past the old head-first budget's + // reach. #[test] fn subtitles_beyond_the_old_head_budget_are_observed() { let pid = 0x1200u16; @@ -1803,9 +1775,8 @@ mod tests { ); } - // The allocation, not just the total: the budget must be spread across - // the extent, not poured into its head. See docs/pgs-forced-probe.md - // (`the_budget_is_spread_across_the_extent`). + // The allocation, not just the total: the budget must be spread across the extent, not + // poured into its head. #[test] fn the_budget_is_spread_across_the_extent() { let pid = 0x1200u16; @@ -1879,9 +1850,8 @@ mod tests { ); } - // The memoisation hazard: a sampled read's evidence must not be replayed - // to a playlist that would have read far more of the extent. See - // docs/pgs-forced-probe.md (`a_thin_sample_is_not_replayed_to_a_playlist_that_would_read_more`). + // The memoisation hazard: a sampled read's evidence must not be replayed to a playlist that + // would have read far more of the extent. #[test] fn a_thin_sample_is_not_replayed_to_a_playlist_that_would_read_more() { let pid = 0x1200u16; @@ -1955,9 +1925,8 @@ mod tests { ); } - // Demotion — the case the guard exists for: on a disc whose authoring - // never sets `forced_on_flag`, its absence says nothing about any track. - // See docs/pgs-forced-probe.md (`a_disc_that_never_sets_the_forced_flag_cannot_demote_anything`). + // Demotion — the case the guard exists for: on a disc whose authoring never sets + // `forced_on_flag`, its absence says nothing about any track. #[test] fn a_disc_that_never_sets_the_forced_flag_cannot_demote_anything() { let pid = 0x1200u16; @@ -2017,9 +1986,8 @@ mod tests { ); } - // The other side of the guard: a track SHAPED like a forced track keeps - // its label even on a disc that uses the flag. See docs/pgs-forced-probe.md - // (`a_forced_shaped_track_keeps_its_label_on_a_disc_that_uses_the_flag`). + // The other side of the guard: a track SHAPED like a forced track keeps its label even on a + // disc that uses the flag. #[test] fn a_forced_shaped_track_keeps_its_label_on_a_disc_that_uses_the_flag() { let small = 0x1200u16; @@ -2141,9 +2109,8 @@ mod tests { } } - // Coverage is what makes a memo replayable: an entry from a thin sample - // must not answer a question needing a thorough one. See - // docs/pgs-forced-probe.md (`cached_evidence_answers_only_what_its_coverage_supports`). + // Coverage is what makes a memo replayable: an entry from a thin sample must not answer a + // question needing a thorough one. #[test] fn cached_evidence_answers_only_what_its_coverage_supports() { let absence = CachedEvidence { @@ -2189,9 +2156,8 @@ mod tests { ); } - // A playlist may list the SAME clip twice; the second read must merge - // into the extent's memo, not double `displays`. See docs/pgs-forced-probe.md - // (`re_reading_one_extent_merges_its_memo_instead_of_doubling_it`). + // A playlist may list the SAME clip twice; the second read must merge into the extent's + // memo, not double `displays`. #[test] fn re_reading_one_extent_merges_its_memo_instead_of_doubling_it() { let pid = 0x1200u16; @@ -2225,9 +2191,8 @@ mod tests { ); } - // The tail of a sampled run must not be discarded: the demuxer holds the - // last PES open waiting for the next PUSI, which sampling may not supply. - // See docs/pgs-forced-probe.md (`the_last_display_set_of_a_run_is_not_thrown_away`). + // The tail of a sampled run must not be discarded: the demuxer holds the last PES open + // waiting for the next PUSI, which sampling may not supply. #[test] fn the_last_display_set_of_a_run_is_not_thrown_away() { let pid = 0x1200u16; @@ -2250,9 +2215,8 @@ mod tests { ); } - // A single-window extent's window must not sit at the extent's head — the - // opening of the feature reliably has no subtitles. See - // docs/pgs-forced-probe.md (`a_single_window_sample_is_taken_from_the_middle_of_the_extent`). + // A single-window extent's window must not sit at the extent's head — the opening of the + // feature reliably has no subtitles. #[test] fn a_single_window_sample_is_taken_from_the_middle_of_the_extent() { let sectors = 524_288u32; @@ -2266,9 +2230,7 @@ mod tests { ); } - // Promotion is an absence claim too, and a SAMPLE cannot support it off - // one display set. See docs/pgs-forced-probe.md - // (`one_display_set_in_a_sampled_run_does_not_prove_a_track_forced`). + // Promotion is an absence claim too, and a SAMPLE cannot support it off one display set. #[test] fn one_display_set_in_a_sampled_run_does_not_prove_a_track_forced() { let pid = 0x1200u16; @@ -2292,9 +2254,8 @@ mod tests { ); } - // ...but a COMPLETE read has no unread gap to hide a non-forced set in, - // so a genuine single-sign forced track is still promoted. See - // docs/pgs-forced-probe.md (`a_complete_read_may_still_promote_from_one_display_set`). + // ...but a COMPLETE read has no unread gap to hide a non-forced set in, so a genuine + // single-sign forced track is still promoted. #[test] fn a_complete_read_may_still_promote_from_one_display_set() { let pid = 0x1200u16; diff --git a/src/disc/profile.rs b/src/disc/profile.rs index b8e70217..89b8db9e 100644 --- a/src/disc/profile.rs +++ b/src/disc/profile.rs @@ -5,8 +5,6 @@ //! [`DiscTitle`] / [`Stream`] shapes; this module hoists those into a flat, //! serde-friendly surface (`profile.titles[i].subtitles[j].forced`, etc.) with //! every field always populated — no per-format conditionals, no bare `Option`. -//! -//! See docs/disc-profile.md for how fields are derived from the model. use serde::{Deserialize, Serialize}; diff --git a/src/drive/linux.rs b/src/drive/linux.rs index 8bc538b6..6966aa11 100644 --- a/src/drive/linux.rs +++ b/src/drive/linux.rs @@ -34,9 +34,8 @@ pub fn find_drives() -> Vec<(String, DriveId)> { drives } -// Enumerate `sg*` names via `/sys/class/scsi_generic/` (exact present-device -// list); fall back to a bounded `sg0..15` probe if sysfs is unreadable. -// See docs/drive-linux.md — why a fixed sg range is unsafe. +// Enumerate `sg*` names via `/sys/class/scsi_generic/` (exact present-device list); fall back +// to a bounded `sg0..15` probe if sysfs is unreadable. fn enumerate_sg_names() -> Vec<String> { let mut names = Vec::new(); if let Ok(entries) = std::fs::read_dir("/sys/class/scsi_generic") { diff --git a/src/drive/mod.rs b/src/drive/mod.rs index fb5124d2..e975fb72 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -77,9 +77,8 @@ const SCSI_MODE_SENSE: u8 = 0x5A; const SCSI_MODE_SELECT: u8 = 0x55; const SCSI_REPORT_KEY: u8 = 0xA4; -// SBC/MMC Read-Write Error Recovery mode page. Flipping PER makes the drive -// REPORT recovered reads instead of silently returning best-effort GOOD data. -// See docs/drive-mod.md — Error-recovery mode page (PER bit). +// SBC/MMC Read-Write Error Recovery mode page. Flipping PER makes the drive REPORT recovered +// reads instead of silently returning best-effort GOOD data. const MODE_PAGE_ERROR_RECOVERY: u8 = 0x01; /// Bit masks in the Read-Write Error Recovery flags byte (page byte 2). const ERP_FLAG_TB: u8 = 0x20; // Transfer Block: still deliver the recovered data @@ -124,11 +123,10 @@ pub struct Drive { /// (firmware/vendor unlock de-busses at the drive, or the disc carries no bus /// encryption). bus_stage: crate::sector::bus_removal::BusStage, - /// Encrypted-content extent map (sorted `(start_lba, sector_count)`) that - /// gates the host-key de-bus to content sectors — clear UDF/nav sectors read - /// during a whole-disc pass are left untouched. `None` = de-bus every read - /// sector (a content-only reader). Ignored entirely under - /// `BusStage::Passthrough`. See docs/drive-mod.md — bus-removal wiring. + /// Encrypted-content extent map (sorted `(start_lba, sector_count)`) that gates the + /// host-key de-bus to content sectors — clear UDF/nav sectors read during a whole-disc pass + /// are left untouched. `None` = de-bus every read sector (a content-only reader). Ignored + /// entirely under `BusStage::Passthrough`. bus_content_ranges: Option<Arc<[(u32, u32)]>>, /// Linux only: raw fd for the corresponding block device (`/dev/sr*`) /// used as a recovery fallback when SCSI READ via `/dev/sg*` returns @@ -811,12 +809,11 @@ impl Drive { /// Read sectors from the disc. Single-shot — no inline retries, no /// SCSI reset. /// - /// `recovery=true` uses [`crate::scsi::READ_RECOVERY_TIMEOUT_MS`] (60 s, - /// matches sg_dd) for the `freemkv_engine::recovery::patch` pass; - /// `recovery=false` uses [`crate::scsi::READ_TIMEOUT_MS`] (10 s) for - /// `freemkv_engine::recovery::copy`'s fast skip-forward sweep. On any - /// failure returns `Err(DiscRead)` immediately; orchestration handles - /// retry policy. See docs/drive-mod.md — single-shot contract. + /// `recovery=true` uses [`crate::scsi::READ_RECOVERY_TIMEOUT_MS`] (60 s, matches sg_dd) for + /// the `freemkv_engine::recovery::patch` pass; `recovery=false` uses + /// [`crate::scsi::READ_TIMEOUT_MS`] (10 s) for `freemkv_engine::recovery::copy`'s fast + /// skip-forward sweep. On any failure returns `Err(DiscRead)` immediately; orchestration + /// handles retry policy. pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> { // Bulk path: FUA off (the drive cache IS the streaming throughput). self.read_fua(lba, count, buf, recovery, false) @@ -1167,9 +1164,8 @@ impl Drop for Drive { } } -// Resolve a `/dev/sg*` path to its `/dev/sr*` block device via sysfs, open -// for read (no O_DIRECT). None on any error ("no fallback available"). -// See docs/drive-mod.md — open_block_device_for_sg. +// Resolve a `/dev/sg*` path to its `/dev/sr*` block device via sysfs, open for read (no +// O_DIRECT). None on any error ("no fallback available"). #[cfg(target_os = "linux")] fn open_block_device_for_sg(sg_path: &Path) -> Option<std::os::unix::io::RawFd> { let basename = sg_path.file_name()?.to_str()?; @@ -1247,10 +1243,9 @@ impl SectorSource for Drive { /// that currently has media**. /// /// Opens each candidate in enumeration order and returns the first reporting -/// [`DriveStatus::DiscPresent`] via [`Drive::drive_status`]; falls back to -/// the first drive that opened if none report a disc. For just listing -/// drives without opening, use `scsi::list_drives()` instead. -/// See docs/drive-mod.md — find_drive selection policy. +/// [`DriveStatus::DiscPresent`] via [`Drive::drive_status`]; falls back to the first drive that +/// opened if none report a disc. For just listing drives without opening, use +/// `scsi::list_drives()` instead. pub fn find_drive() -> Option<Drive> { #[cfg(target_os = "macos")] let candidates = crate::scsi::list_drives() @@ -1357,9 +1352,8 @@ pub(crate) fn decode_read_capacity(buf: &[u8; 8], bytes_transferred: usize) -> R last_lba.checked_add(1).ok_or(Error::DiscCapacityOverflow) } -// Halt-aware sleep primitive — wakes within ~100 ms of `halt` flipping true, -// returning Error::Halted. Used by wait_ready's poll backoff and -// spin_cycle's spin-down/settle pauses. See docs/drive-mod.md — sleep_until_halted. +// Halt-aware sleep primitive — wakes within ~100 ms of `halt` flipping true, returning +// Error::Halted. Used by wait_ready's poll backoff and spin_cycle's spin-down/settle pauses. fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<()> { const SLICE: std::time::Duration = std::time::Duration::from_millis(100); let deadline = std::time::Instant::now() + total; @@ -1390,9 +1384,8 @@ pub enum DeviceResolution { SrNoSgMatch, } -// Resolve a device path to its raw SCSI device; returns the resolved path -// plus a DeviceResolution signal. Staged, not yet wired — allow(dead_code) -// is deliberate. See docs/drive-mod.md — resolve_device. +// Resolve a device path to its raw SCSI device; returns the resolved path plus a +// DeviceResolution signal. Staged, not yet wired — allow(dead_code) is deliberate. #[allow(dead_code)] pub(crate) fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> { platform::resolve_device(path) @@ -1833,7 +1826,6 @@ mod command_tests { } // disc_is_dvd() must match ONLY the DVD profile family (0x0010..=0x001F). - // See docs/drive-mod.md — disc_is_dvd_matches_only_dvd_profile_family. #[test] fn disc_is_dvd_matches_only_dvd_profile_family() { let probe = |profile: u16| { @@ -1887,9 +1879,8 @@ mod command_tests { assert_eq!(d.drive_status(), DriveStatus::DiscPresent); } - // Byte 5 is a Media Status only when NEA is clear AND class == Media; - // otherwise it must fall back to TUR, not decode a reserved byte. - // See docs/drive-mod.md — media-status decoding. + // Byte 5 is a Media Status only when NEA is clear AND class == Media; otherwise it must + // fall back to TUR, not decode a reserved byte. #[test] fn drive_status_rejects_a_reply_carrying_no_media_event_descriptor() { // NEA = 1: "No Event Available" — no descriptor was returned, so the @@ -2327,9 +2318,8 @@ mod command_tests { } } - // An undersized caller buffer must error, not panic, whether the request - // fits in one transfer or has to be chunked. See docs/drive-mod.md — - // chunked-read tests. + // An undersized caller buffer must error, not panic, whether the request fits in one + // transfer or has to be chunked. #[test] fn an_undersized_buffer_errors_on_the_chunked_path_just_like_the_single_one() { // max_transfer = 4 sectors, so a 10-sector read must chunk. @@ -2444,9 +2434,8 @@ mod command_tests { } } - // Writes a distinct marker per chunk and checks the BYTE offset, catching - // a `*` -> `+`/`/` mutation in the chunk destination-slice arithmetic. - // See docs/drive-mod.md — chunked-read tests. + // Writes a distinct marker per chunk and checks the BYTE offset, catching a `*` -> `+`/`/` + // mutation in the chunk destination-slice arithmetic. #[test] fn read_chunks_write_into_correctly_offset_buffer_regions() { // max_transfer = 4 sectors (8192 bytes): a 10-sector read at LBA 0 @@ -2468,8 +2457,8 @@ mod command_tests { ); } - // Multi-chunk path with an undersized buffer must error, not panic, same - // as the single-chunk path. See docs/drive-mod.md — chunked-read tests. + // Multi-chunk path with an undersized buffer must error, not panic, same as the + // single-chunk path. #[test] fn undersized_buffer_multi_chunk_errors_not_panics() { let ChunkingHarness { @@ -2840,8 +2829,7 @@ mod command_tests { ); } - // A Stop pressed before the poll starts must be answered at once, not - // after the ~30 s poll. See docs/drive-mod.md — wait_ready/spin_cycle halt tests. + // A Stop pressed before the poll starts must be answered at once, not after the ~30 s poll. #[test] fn wait_ready_returns_halted_when_stopped_before_the_poll() { struct NeverReady; @@ -2874,8 +2862,7 @@ mod command_tests { ); } - // A Stop pressed part way through the poll must be answered at the next - // command boundary. See docs/drive-mod.md — wait_ready/spin_cycle halt tests. + // A Stop pressed part way through the poll must be answered at the next command boundary. #[test] fn wait_ready_returns_halted_when_stopped_during_the_poll() { struct StopsOnThirdPoll { @@ -2925,8 +2912,7 @@ mod command_tests { ); } - // spin_cycle must not be deaf to Stop for its ~15 s of deliberate - // waiting. See docs/drive-mod.md — wait_ready/spin_cycle halt tests. + // spin_cycle must not be deaf to Stop for its ~15 s of deliberate waiting. #[test] fn spin_cycle_returns_halted_when_stopped_before_it_starts() { let RecordingHarness { @@ -2951,8 +2937,7 @@ mod command_tests { ); } - // A Stop that lands DURING the spin-down pause must wake it (halt-aware - // sleep, ~100 ms). See docs/drive-mod.md — wait_ready/spin_cycle halt tests. + // A Stop that lands DURING the spin-down pause must wake it (halt-aware sleep, ~100 ms). #[test] fn spin_cycle_wakes_from_its_spin_down_pause_when_stopped() { let RecordingHarness { @@ -2980,8 +2965,7 @@ mod command_tests { ); } - // A READ(10) with GOOD status but a residual underrun must be refused - // AND logged. See docs/drive-mod.md — read_logs_a_good_status_short_transfer. + // A READ(10) with GOOD status but a residual underrun must be refused AND logged. #[test] fn read_logs_a_good_status_short_transfer() { let RecordingHarness { diff --git a/src/drive/windows.rs b/src/drive/windows.rs index 967b5bd9..aa9ee526 100644 --- a/src/drive/windows.rs +++ b/src/drive/windows.rs @@ -58,9 +58,7 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> { Ok((normalize_path(path), DeviceResolution::Direct)) } -// Normalize a device path ("D:", "D:\\", "\\.\D:", "\\.\CdRom0") to -// Windows \\.\X: format. See docs/drive-access.md — Windows Path -// Normalization. +// Normalize a device path ("D:", "D:\\", "\\.\D:", "\\.\CdRom0") to Windows \\.\X: format. fn normalize_path(path: &str) -> String { if path.starts_with("\\\\.\\") { return path.to_string(); diff --git a/src/dvdnav/mod.rs b/src/dvdnav/mod.rs index 62942629..c834fb9d 100644 --- a/src/dvdnav/mod.rs +++ b/src/dvdnav/mod.rs @@ -5,7 +5,6 @@ //! logo/warning segment when the disc's own navigation does). //! //! Contents: [`vmcmd`] (VM command decoder), [`nav`] (First-Play executor). -//! See docs/dvdnav-mod.md — byte-layout basis and module contents detail. pub(crate) mod nav; pub(crate) mod vmcmd; @@ -29,9 +28,8 @@ pub fn resolve_feature_start( vtsn: u16, vts_ttn: u16, ) -> Option<usize> { - // PARKED stub (issue #40, `USE_NAV_RESOLVER=false`) — intentionally always - // returns `None`; do not delete. `reader`/`udf` are unused seam inputs. - // See docs/dvdnav-mod.md for full rationale. + // PARKED stub (issue #40, `USE_NAV_RESOLVER=false`) — intentionally always returns `None`; + // do not delete. `reader`/`udf` are unused seam inputs. let _ = (reader, udf); tracing::trace!( target: "freemkv::dvdnav", diff --git a/src/dvdnav/nav.rs b/src/dvdnav/nav.rs index d5f3b669..44215972 100644 --- a/src/dvdnav/nav.rs +++ b/src/dvdnav/nav.rs @@ -1,5 +1,5 @@ -//! DVD-Video navigation executor — the DVD arm of "mimic a real player" title -//! selection (issue #40). See docs/nav.md for full rationale and design. +//! DVD-Video navigation executor — the DVD arm of "mimic a real player" title selection (issue +//! #40). //! //! Reads the First-Play PGC pre-command list from `VIDEO_TS.IFO`, executes it //! on a minimal register machine, and when it deterministically reaches a @@ -81,12 +81,12 @@ fn is_sprm(idx: u8) -> bool { // ── Minimal navigation register machine ────────────────────────────────────── -// Minimal VM register file (GPRMs + SPRMs) for the First-Play resolver, cold -// (all-zero) at start. See docs/nav.md — "The Vm register machine". +// Minimal VM register file (GPRMs + SPRMs) for the First-Play resolver, cold (all-zero) at +// start. struct Vm { gprm: [u16; 16], sprm: [u16; 24], - // Per-GPRM taint: set when SPRM-derived. See docs/nav.md. + // Per-GPRM taint: set when SPRM-derived. gprm_tainted: [bool; 16], } @@ -128,9 +128,8 @@ impl Vm { } } - // Evaluate a compare predicate (op codes per [`Compare`]); returns (result, - // taint). Taint marks an SPRM-dependent (undecidable) branch. See - // docs/nav.md — "Vm::eval — compare taint". + // Evaluate a compare predicate (op codes per [`Compare`]); returns (result, taint). Taint + // marks an SPRM-dependent (undecidable) branch. fn eval(&self, c: &Compare) -> (bool, bool) { let mut tainted = self.reg_tainted(c.lhs_reg); let l = self.reg(c.lhs_reg); @@ -396,8 +395,7 @@ mod tests { // ── Fixture builders ──────────────────────────────────────────────────── - // Assemble a spec-shaped VMGI image (FP_PGC + TT_SRPT). See docs/nav.md — - // "Test fixture: build_vmgi". + // Assemble a spec-shaped VMGI image (FP_PGC + TT_SRPT). fn build_vmgi(pre: &[[u8; 8]], tt_srpt_sector: u32, titles: &[(u8, u8)]) -> Vec<u8> { // Place the FP_PGC at a fixed byte offset past the header. let fp_pgc_off: u32 = 0x400; @@ -602,8 +600,7 @@ mod tests { assert_eq!(resolve_from_vmg(&vmgi), None); } - // SPRM-gated dispatch is undecidable at scan time and must abstain. See - // docs/nav.md — "Regression test rationale". + // SPRM-gated dispatch is undecidable at scan time and must abstain. #[test] fn sprm_gated_dispatch_abstains() { // 30 22: jump with EQ compare (op=2); if_v2 operands are registers @@ -630,9 +627,8 @@ mod tests { ); } - // Defect-1 regression: a store guarded by taint must not abstain the - // unconditional dispatch that follows. See docs/nav.md — "Regression - // test rationale". + // Defect-1 regression: a store guarded by taint must not abstain the unconditional dispatch + // that follows. #[test] fn sprm_guarded_store_then_unconditional_dispatch_resolves() { // line 0: 71 20 | lhs=b2=0x80 (SPRM0), cmp EQ(op=2, register), rhs=b7=0 @@ -653,8 +649,8 @@ mod tests { ); } - // Defect-2 regression: an SPRM laundered through a GPRM must still taint a - // later GPRM-only branch. See docs/nav.md — "Regression test rationale". + // Defect-2 regression: an SPRM laundered through a GPRM must still taint a later GPRM-only + // branch. #[test] fn sprm_laundered_through_gprm_abstains() { // line 0: 61 00 | SetGPRM g0 = SPRM20 (mov, register src=b5=0x94). diff --git a/src/error.rs b/src/error.rs index b8569c0b..d7f97d4b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,8 +2,6 @@ //! //! Every error is a code with structured data. No English text. //! Applications map codes to localized messages. -//! -//! See docs/error-codes.md for the full E1xxx-E9xxx range table. // ── Error codes ───────────────────────────────────────────────────────────── @@ -60,9 +58,8 @@ pub const E_UDF_EMBEDDED_DATA: u16 = 6018; /// which is zero-length or points at LBA 0. Reported by the HD-DVD clip /// resolver (`disc::hddvd`). /// -/// It has no [`Error`] variant on purpose: `UdfFs::file_extents` returns -/// `Ok(vec![])` here and the CALLER detects the condition. See -/// docs/error-codes.md for why. +/// It has no [`Error`] variant on purpose: `UdfFs::file_extents` returns `Ok(vec![])` here and +/// the CALLER detects the condition. pub const E_UDF_NO_USABLE_EXTENT: u16 = 6019; // AACS (7xxx) @@ -93,22 +90,19 @@ pub const E_CSS_KEY_MISSING: u16 = 7023; pub const E_AACS_NO_HOST_CERT: u16 = 7024; pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025; pub const E_FMTS_KEY_MISSING: u16 = 7026; -/// The CSS disc as a WHOLE could not be decrypted — the scan saw scrambled -/// sectors and the known-plaintext crack recovered no title key at all, so every -/// title will fail identically. The CSS analogue of [`E_NO_DISC_KEY`], and -/// deliberately NOT [`E_CSS_KEY_MISSING`], which [`is_skippable_title_stub`] -/// treats as a skippable per-title stub. [`is_disc_level_no_key`] classifies -/// this code, so a multi-title rip loop fails fast on it. See -/// docs/error-codes.md for the history of why these codes were split. +/// The CSS disc as a WHOLE could not be decrypted — the scan saw scrambled sectors and the +/// known-plaintext crack recovered no title key at all, so every title will fail identically. +/// The CSS analogue of [`E_NO_DISC_KEY`], and deliberately NOT [`E_CSS_KEY_MISSING`], which +/// [`is_skippable_title_stub`] treats as a skippable per-title stub. [`is_disc_level_no_key`] +/// classifies this code, so a multi-title rip loop fails fast on it. pub const E_CSS_NO_DISC_KEY: u16 = 7027; /// A key SOURCE could not be reached, or failed on its own side — transport /// error, DNS failure, timeout, TLS failure, an HTTP 5xx, or a reply the client /// could not read. The source never got as far as answering the question, so /// nothing at all is known about whether a key for this disc exists. /// -/// Deliberately NOT [`E_NO_DISC_KEY`], which asserts the OPPOSITE — every source -/// answered and none holds a key. Transient: retry later. See -/// docs/error-codes.md for why conflating the two is dangerous. +/// Deliberately NOT [`E_NO_DISC_KEY`], which asserts the OPPOSITE — every source answered and +/// none holds a key. Transient: retry later. pub const E_KEY_SERVICE_UNAVAILABLE: u16 = 7028; /// A key source rejected the configured credentials (HTTP 401/403 from the online /// key service). NOT transient and NOT an absent key — the operator action is to @@ -234,8 +228,7 @@ pub const E_MKV_LACING_INVALID: u16 = 9052; /// allocation caps, an out-of-range TimestampScale / cluster timestamp / track /// number). /// -/// Deliberately NOT [`E_MKV_INVALID`] (a no-frames stub, skippable). See -/// docs/error-codes.md for why conflating the two is dangerous. +/// Deliberately NOT [`E_MKV_INVALID`] (a no-frames stub, skippable). pub const E_MKV_SOURCE_INVALID: u16 = 9053; /// The Matroska WRITER was asked to emit something EBML cannot represent: an /// element body at or above the 56-bit VINT payload limit (which would encode @@ -417,11 +410,10 @@ pub enum Error { /// A file's ICB declares its data EMBEDDED inline (ECMA-167 4/14.6.8 /// allocation-descriptor type 3), so it has no out-of-line extents at all. /// - /// Returned only when a caller asked for a read plan over such a file; - /// decoding the inline bytes as (length, LBA) pairs would manufacture - /// extents out of file content and point the reader at unrelated sectors. - /// Callers that expect an embedded file (e.g. AACS `*.inf`) read it via - /// `read_inline_data` instead. See docs/error-codes.md for more. + /// Returned only when a caller asked for a read plan over such a file; decoding the inline + /// bytes as (length, LBA) pairs would manufacture extents out of file content and point the + /// reader at unrelated sectors. Callers that expect an embedded file (e.g. AACS `*.inf`) + /// read it via `read_inline_data` instead. UdfEmbeddedData, DiscTitleRange { index: usize, @@ -547,14 +539,12 @@ pub enum Error { /// all-titles rip skips just this title. The whole-disc counterpart is /// [`Error::CssNoDiscKey`]. CssKeyMissing, - /// The disc is CSS-encrypted and decryption was requested, but the - /// known-plaintext crack recovered NO title key for the disc at all (the scan - /// saw scrambled sectors and stamped `Disc::css_error`). A whole-disc - /// condition: every title would fail the same way, so a multi-title rip loop - /// must stop instead of iterating. The CSS analogue of [`Error::NoDiscKey`], - /// classified by [`is_disc_level_no_key`] — NOT [`is_skippable_title_stub`], - /// which owns the per-title [`Error::CssKeyMissing`]. See docs/error-codes.md - /// for why conflating the two matters. + /// The disc is CSS-encrypted and decryption was requested, but the known-plaintext crack + /// recovered NO title key for the disc at all (the scan saw scrambled sectors and stamped + /// `Disc::css_error`). A whole-disc condition: every title would fail the same way, so a + /// multi-title rip loop must stop instead of iterating. The CSS analogue of + /// [`Error::NoDiscKey`], classified by [`is_disc_level_no_key`] — NOT + /// [`is_skippable_title_stub`], which owns the per-title [`Error::CssKeyMissing`]. CssNoDiscKey, /// A key source could not be reached, or failed on its own side — transport /// error, DNS failure, timeout, TLS failure, HTTP 5xx, or an unreadable / @@ -582,14 +572,13 @@ pub enum Error { AacsNoHostCert { path: String, }, - /// A bus-encrypted disc (AACS 2.0 / UHD, Content Certificate bus-encryption - /// bit set) was scanned on a live drive, the Volume ID was obtained, but no - /// `read_data_key` (bus key) was produced — so the on-disc bytes are still - /// bus-encrypted and would decrypt to garbage. The bus key is derivable ONLY - /// from the AACS host-certificate cert-auth handshake; a VID-only OEM unlock - /// path is insufficient for such a disc. Surfaced instead of silently - /// producing a corrupt rip. NOT raised for AACS 1.0 BD or file-backed (ISO) - /// scans, where no bus-key handshake runs. See docs/error-codes.md for detail. + /// A bus-encrypted disc (AACS 2.0 / UHD, Content Certificate bus-encryption bit set) was + /// scanned on a live drive, the Volume ID was obtained, but no `read_data_key` (bus key) + /// was produced — so the on-disc bytes are still bus-encrypted and would decrypt to + /// garbage. The bus key is derivable ONLY from the AACS host-certificate cert-auth + /// handshake; a VID-only OEM unlock path is insufficient for such a disc. Surfaced instead + /// of silently producing a corrupt rip. NOT raised for AACS 1.0 BD or file-backed (ISO) + /// scans, where no bus-key handshake runs. AacsBusKeyUnavailable, /// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment @@ -654,9 +643,8 @@ pub enum Error { /// frames but its codec init data never appears, so buffering further would /// swap the box to death. Carries the buffered byte count. /// - /// DISTINCT from [`Error::MkvInvalid`] (an empty nav/menu stub, skippable): - /// real frames with unresolvable headers is a main feature, not a stub. - /// This code is not skippable. See docs/error-codes.md for the history. + /// DISTINCT from [`Error::MkvInvalid`] (an empty nav/menu stub, skippable): real frames + /// with unresolvable headers is a main feature, not a stub. This code is not skippable. MuxHeaderBufferExceeded { bytes: u64, }, @@ -745,10 +733,9 @@ pub enum Error { /// an error or before delivering the extents it was given — so it can /// never return another byte. /// - /// It exists because `Ok(0)` from a dead source is indistinguishable from - /// end-of-stream, and callers may legitimately treat a short read as a - /// skippable hole. This cannot be retried or skipped past; every consumer - /// must abort the pass on it. See docs/error-codes.md for more. + /// It exists because `Ok(0)` from a dead source is indistinguishable from end-of-stream, + /// and callers may legitimately treat a short read as a skippable hole. This cannot be + /// retried or skipped past; every consumer must abort the pass on it. SourceTerminated, /// An MPEG-TS packet under construction violated the 188-byte fixed /// size (over-long adaptation field, overflowing payload, or a @@ -1300,8 +1287,8 @@ pub type Result<T> = std::result::Result<T, Error>; /// predicates built on it below — parsing the `E<code>` prefix is this /// function's job, so callers don't reimplement it. /// -/// [`From<Error> for io::Error`] is the ONLY path from a typed [`Error`] to an -/// `io::Error` in this crate; see docs/error-codes.md for the round-trip detail. +/// [`From<Error> for io::Error`] is the ONLY path from a typed [`Error`] to an `io::Error` in +/// this crate. pub fn error_code(e: &std::io::Error) -> Option<u16> { // Round-tripped: `From<Error> for io::Error` renders as "E<code>[: …]". let s = e.to_string(); @@ -1317,9 +1304,8 @@ pub fn error_code(e: &std::io::Error) -> Option<u16> { /// that produced no muxable frames ([`Error::MkvInvalid`]). An all-titles rip /// skips such a title and finishes the rest; every other error stays fatal. /// -/// NOT skippable: a broken `mkv://` source ([`Error::MkvSourceInvalid`] and -/// siblings) or a whole-disc key failure ([`Error::CssNoDiscKey`], see -/// [`is_disc_level_no_key`]). See docs/error-codes.md for why. +/// NOT skippable: a broken `mkv://` source ([`Error::MkvSourceInvalid`] and siblings) or a +/// whole-disc key failure ([`Error::CssNoDiscKey`], see [`is_disc_level_no_key`]). pub fn is_skippable_title_stub(e: &std::io::Error) -> bool { matches!(error_code(e), Some(E_MKV_INVALID | E_CSS_KEY_MISSING)) } @@ -1333,14 +1319,12 @@ pub fn is_halt(e: &std::io::Error) -> bool { error_code(e) == Some(E_HALTED) } -/// Whether an [`io::Error`](std::io::Error) is a **disc-level** key failure — -/// the disc as a whole cannot be decrypted, so EVERY title will fail the same -/// way. Distinct from a per-title skippable stub ([`is_skippable_title_stub`]). -/// Covers `E_NO_DISC_KEY`, `E_KEYDB_LOAD`, `E_AACS_NO_KEYS`, `E_CSS_NO_DISC_KEY`, -/// and the key-SOURCE failures (`E_KEY_SERVICE_UNAVAILABLE`/`UNAUTHORIZED`/ -/// `RATE_LIMITED`) — a down/throttled/unauthorized source fails every title too. -/// A multi-title rip loop should fail fast rather than iterate. See -/// docs/error-codes.md for why each code belongs here. +/// Whether an [`io::Error`](std::io::Error) is a **disc-level** key failure — the disc as a +/// whole cannot be decrypted, so EVERY title will fail the same way. Distinct from a per-title +/// skippable stub ([`is_skippable_title_stub`]). Covers `E_NO_DISC_KEY`, `E_KEYDB_LOAD`, +/// `E_AACS_NO_KEYS`, `E_CSS_NO_DISC_KEY`, and the key-SOURCE failures +/// (`E_KEY_SERVICE_UNAVAILABLE`/`UNAUTHORIZED`/ `RATE_LIMITED`) — a down/throttled/unauthorized +/// source fails every title too. A multi-title rip loop should fail fast rather than iterate. pub fn is_disc_level_no_key(e: &std::io::Error) -> bool { matches!( error_code(e), @@ -1431,9 +1415,8 @@ impl Error { /// sometimes recover the data: MEDIUM ERROR (3), ABORTED COMMAND (B), /// NOT READY (2), RECOVERED ERROR (1), or NO SENSE (0). /// - /// `false` for transport failures, HARDWARE ERROR, DATA PROTECT, UNIT - /// ATTENTION, ILLEGAL REQUEST, BLANK CHECK, `IoError`, and non-SCSI - /// variants. See docs/error-codes.md for BU40N specifics and callers. + /// `false` for transport failures, HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, ILLEGAL + /// REQUEST, BLANK CHECK, `IoError`, and non-SCSI variants. pub fn is_marginal_read(&self) -> bool { self.scsi_sense() .map(crate::scsi::ScsiSense::is_marginal) @@ -1495,9 +1478,8 @@ mod tests { assert!(!is_skippable_title_stub(&cap)); } - // The two CSS no-key conditions land on opposite sides of the per-title / - // whole-disc split (mirroring the AACS pair). See docs/error-codes.md for - // the incident that motivated the split. + // The two CSS no-key conditions land on opposite sides of the per-title / whole-disc split + // (mirroring the AACS pair). #[test] fn css_no_key_codes_split_disc_level_from_skippable() { let wide: std::io::Error = Error::CssNoDiscKey.into(); @@ -1840,8 +1822,8 @@ mod tests { // ── New comprehensive tests ──────────────────────────────────────────────── - // Every `pub const E_*`, parsed from source (not hand-listed) so a new - // constant can't escape the uniqueness check below. See docs/error-codes.md. + // Every `pub const E_*`, parsed from source (not hand-listed) so a new constant can't + // escape the uniqueness check below. fn declared_error_codes() -> Vec<(&'static str, u16)> { const SRC: &str = include_str!("error.rs"); SRC.lines() diff --git a/src/halt.rs b/src/halt.rs index e582b633..5a92ac4d 100644 --- a/src/halt.rs +++ b/src/halt.rs @@ -4,8 +4,6 @@ //! every long-running loop; the loop polls `is_cancelled()` and bails out //! cleanly. Calling `cancel()` from any clone flips the shared flag, and //! every other clone observes it on its next poll. -//! -//! See docs/halt.md — why `Ordering::Relaxed` is sufficient here. use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -67,8 +65,6 @@ impl Default for Halt { /// `bounded_syscall` checks the cancellation flag and the deadline /// every [`POLL_INTERVAL`] while blocked on a worker; the same /// cadence governs `Pipeline::send_with_halt`'s `try_send` retry. -/// -/// See docs/halt.md — why 250ms, and why this constant is centralised. pub const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250); #[cfg(test)] diff --git a/src/harness.rs b/src/harness.rs index 2e3fa130..a67aa370 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -6,8 +6,7 @@ //! exactly that: **the parser returns `Ok` or `Err`, and never panics.** //! //! `FREEMKV_HARNESS_CASES` sets cases per generator per target (default 256). -//! `FREEMKV_HARNESS_SEED` overrides the seed to reproduce a CI failure -//! locally. See docs/harness.md for rationale and the on-failure workflow. +//! `FREEMKV_HARNESS_SEED` overrides the seed to reproduce a CI failure locally. #![cfg(test)] diff --git a/src/hex.rs b/src/hex.rs index 744bffd3..24c67974 100644 --- a/src/hex.rs +++ b/src/hex.rs @@ -1,9 +1,9 @@ //! The single hex → bytes parser for the whole workspace. //! -//! Key material arrives as hex from three third-party sources that each used -//! to parse it slightly differently, so a key with a prefix one parser didn't -//! expect was silently dropped. This is the one parser they all call, so the -//! prefix/case/validation rules live in exactly one place. See `docs/hex.md`. +//! Key material arrives as hex from three third-party sources that each used to parse it +//! slightly differently, so a key with a prefix one parser didn't expect was silently dropped. +//! This is the one parser they all call, so the prefix/case/validation rules live in exactly +//! one place. //! //! Operates on BYTES, not `&str` char indices, so a multi-byte UTF-8 scalar //! in untrusted input rejects as malformed rather than panicking mid-codepoint. @@ -87,9 +87,9 @@ pub fn strip_hex_prefix(s: &str) -> &str { .unwrap_or(s) } -// Combine two ASCII hex-digit bytes into one byte. `as char` is intentional: -// non-ASCII bytes become a Latin-1 scalar that `to_digit(16)` then rejects, -// so non-hex/multi-byte input fails cleanly. See `docs/hex.md`. +// Combine two ASCII hex-digit bytes into one byte. `as char` is intentional: non-ASCII bytes +// become a Latin-1 scalar that `to_digit(16)` then rejects, so non-hex/multi-byte input fails +// cleanly. fn byte(hi: u8, lo: u8) -> Option<u8> { let hi = (hi as char).to_digit(16)?; let lo = (lo as char).to_digit(16)?; diff --git a/src/identity.rs b/src/identity.rs index 20af5d60..a22dd7be 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -185,8 +185,8 @@ mod tests { use super::*; use crate::scsi::{ScsiResult, ScsiTransport}; - // Reports a bytes_transferred larger than the caller's buffer — models a - // drive that lies about its transfer count. See docs/identity.md. + // Reports a bytes_transferred larger than the caller's buffer — models a drive that lies + // about its transfer count. struct OversizedCountTransport; impl ScsiTransport for OversizedCountTransport { @@ -262,8 +262,7 @@ mod tests { // ── New comprehensive tests ──────────────────────────────────────────────── - // A short/empty INQUIRY data phase must fail the probe, not present as - // a blank drive. See docs/identity.md. + // A short/empty INQUIRY data phase must fail the probe, not present as a blank drive. #[test] fn inquiry_with_a_short_data_phase_fails_instead_of_reporting_a_blank_drive() { /// GOOD status, no sense, and only `n` bytes written. @@ -376,8 +375,8 @@ mod tests { ); } - // Guard is `data.len() > start`, strictly greater; len == start has no - // byte at that offset, so it must still yield empty. See docs/identity.md. + // Guard is `data.len() > start`, strictly greater; len == start has no byte at that offset, + // so it must still yield empty. #[test] fn ascii_field_boundary_len_equals_start_is_empty() { let buf = vec![0u8; 8]; @@ -394,9 +393,8 @@ mod tests { assert_eq!(ascii_field(&buf, 8, 16), "X"); } - // `Display` renders the four trimmed identity fields space-separated — - // the human-readable counterpart of `match_key`'s pipe-separated form. - // See docs/identity.md. + // `Display` renders the four trimmed identity fields space-separated — the human-readable + // counterpart of `match_key`'s pipe-separated form. #[test] fn display_formats_trimmed_fields_space_separated() { let mut inquiry = vec![0u8; 96]; @@ -408,9 +406,9 @@ mod tests { assert_eq!(id.to_string(), "PIONEER BD-RW BDR-S09 1.34 16/04/"); } - // Transport whose GET CONFIGURATION responses report an exact, - // caller-chosen bytes_transferred for each GC feature, pinning the - // `> 12` boundary guards. INQUIRY always succeeds. See docs/identity.md. + // Transport whose GET CONFIGURATION responses report an exact, caller-chosen + // bytes_transferred for each GC feature, pinning the `> 12` boundary guards. INQUIRY always + // succeeds. struct FixedGcCountTransport { firmware_bytes: usize, serial_bytes: usize, @@ -441,9 +439,8 @@ mod tests { } } - // `end > 12` in the firmware-date branch is strict: count == 12 covers - // bytes 0..12, none of which is the date, so it must report empty. - // See docs/identity.md. + // `end > 12` in the firmware-date branch is strict: count == 12 covers bytes 0..12, none of + // which is the date, so it must report empty. #[test] fn from_drive_firmware_date_boundary_exactly_12_is_empty() { let mut t = FixedGcCountTransport { diff --git a/src/ifo.rs b/src/ifo.rs index 9ee78d1e..42e06907 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -130,12 +130,11 @@ impl CellCategory { impl DvdTitle { /// Index of the first cell to include in the muxed feature. /// - /// Some PGCs open with leading cells that are not part of the movie, - /// identified by a cell-category flagged as a *secondary* piece of an - /// angle/interleave block ([`CellCategory::is_secondary_block_piece`]). - /// Returns the index of the first plain-feature cell; cells before it are - /// dropped from the feature extents. Conservative: only ever skips a - /// prefix, never past the last cell or to zero cells. See docs/ifo.md. + /// Some PGCs open with leading cells that are not part of the movie, identified by a + /// cell-category flagged as a *secondary* piece of an angle/interleave block + /// ([`CellCategory::is_secondary_block_piece`]). Returns the index of the first + /// plain-feature cell; cells before it are dropped from the feature extents. Conservative: + /// only ever skips a prefix, never past the last cell or to zero cells. pub fn feature_start_cell(&self) -> usize { let n = self.cells.len(); if n == 0 { @@ -304,9 +303,7 @@ impl DvdRate { /// (`[hours_bcd, minutes_bcd, seconds_bcd, rate_and_frames]`; byte 3 packs /// the frame-rate flag in bits 7-6 and the frame count in bits 5-0). /// -/// `dvd_time_t` is a timecode, not elapsed wall-clock time — see docs/ifo.md. -/// Returns `None` when the slice is short or the rate flag is unspecified, -/// since without a nominal rate the frame count cannot be interpreted. +/// `dvd_time_t` is a timecode, not elapsed wall-clock time. pub fn bcd_to_frames(bcd: &[u8]) -> Option<(u64, DvdRate)> { if bcd.len() < 4 { return None; @@ -448,14 +445,14 @@ pub(crate) fn parse_vmg_with( Ok(DvdInfo { title_sets }) } -// Maximum TT_SRPT entries honoured (DVD-Video's own 99-title cap). The -// on-disc count is an untrusted u16; without this, a crafted IFO could -// declare 65535 entries and blow up memory re-parsing PGCs. See docs/ifo.md. +// Maximum TT_SRPT entries honoured (DVD-Video's own 99-title cap). The on-disc count is an +// untrusted u16; without this, a crafted IFO could declare 65535 entries and blow up memory +// re-parsing PGCs. pub(crate) const MAX_TT_SRPT_TITLES: usize = 99; -// Parses the VMG TT_SRPT into a per-title-set map of (chapter_count, -// vts_title_number). Clamps the declared entry count to MAX_TT_SRPT_TITLES -// and drops duplicate (vts_number, vts_title_num) pairs. See docs/ifo.md. +// Parses the VMG TT_SRPT into a per-title-set map of (chapter_count, vts_title_number). Clamps +// the declared entry count to MAX_TT_SRPT_TITLES and drops duplicate (vts_number, +// vts_title_num) pairs. fn parse_tt_srpt( vmg_data: &[u8], tt_srpt_offset: usize, @@ -645,9 +642,8 @@ fn parse_video_attr(data: &[u8]) -> Result<DvdVideoAttr> { }) } -// Parses one audio stream attribute block (8 bytes at `offset`). pub(crate) -// so src/mux/mkv.rs's cross-module tests can call the real parser directly. -// See docs/ifo.md for why that's preferable to narrowing visibility. +// Parses one audio stream attribute block (8 bytes at `offset`). pub(crate) so src/mux/mkv.rs's +// cross-module tests can call the real parser directly. pub(crate) fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> { let b0 = byte_at(data, offset)?; let b1 = byte_at(data, offset + 1)?; @@ -688,9 +684,8 @@ pub(crate) fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAtt }) } -// Assigns the on-wire private_stream_1 sub-stream id (codec_base | position, -// saturated at 7) to each audio stream: AC-3 0x80|i, DTS 0x88|i, LPCM 0xA0|i, -// else None. See docs/ifo.md for why position beats a per-codec ordinal. +// Assigns the on-wire private_stream_1 sub-stream id (codec_base | position, saturated at 7) to +// each audio stream: AC-3 0x80|i, DTS 0x88|i, LPCM 0xA0|i, else None. fn assign_audio_sub_stream_ids(streams: &mut [DvdAudioAttr]) { for (i, s) in streams.iter_mut().enumerate() { let n = (i as u8).min(7); @@ -712,9 +707,8 @@ fn parse_subtitle_attr(data: &[u8], offset: usize) -> Result<DvdSubtitleAttr> { Ok(DvdSubtitleAttr { language }) } -// Decodes the raw 2-byte on-disc language code: lowercase a-z taken verbatim, -// all-zero means unspecified (empty), else an ASCII-alphanumeric salvage. See -// docs/ifo.md for why the salvage isn't narrowed to a-z. +// Decodes the raw 2-byte on-disc language code: lowercase a-z taken verbatim, all-zero means +// unspecified (empty), else an ASCII-alphanumeric salvage. fn parse_raw_dvd_lang_bytes(lang_bytes: &[u8]) -> String { if lang_bytes[0] >= b'a' && lang_bytes[0] <= b'z' @@ -733,9 +727,8 @@ fn parse_raw_dvd_lang_bytes(lang_bytes: &[u8]) -> String { } } -// Converts a DVD IFO audio/subtitle language code (ISO 639-1, or empty) to -// ISO 639-2 for Matroska/MP4 language fields; unrecognized/empty -> "und". -// See docs/ifo.md for why the wider iso639_1_to_iso639_2 table is used. +// Converts a DVD IFO audio/subtitle language code (ISO 639-1, or empty) to ISO 639-2 for +// Matroska/MP4 language fields; unrecognized/empty -> "und". fn dvd_lang_to_iso639_2(raw: &str) -> String { crate::labels::vocab::iso639_1_to_iso639_2(raw) .unwrap_or("und") @@ -1301,9 +1294,9 @@ mod tests { assert_eq!(attr.resolution, Resolution::R576i); } - // ABSOLUTE-BYTE pin: other tests build the byte via v_atr_byte(...), which - // shares the parser's shift constants, so a co-edit could hide a bug. This - // feeds parse_video_attr HARDCODED real-layout bytes instead. See docs/ifo.md. + // ABSOLUTE-BYTE pin: other tests build the byte via v_atr_byte(...), which shares the + // parser's shift constants, so a co-edit could hide a bug. This feeds parse_video_attr + // HARDCODED real-layout bytes instead. #[test] fn video_attr_absolute_bytes_pin_real_layout() { // (byte @0x200, expected standard, expected aspect, expected resolution): @@ -1404,9 +1397,9 @@ mod tests { assert_eq!(ids.len(), sorted.len(), "sub-stream ids must be unique"); } - // Regression (The Punisher 2004): audio[0]=AC-3, audio[1]=DTS. DTS at - // position 1 must get wire sub-id 0x89 (0x88|1), not the old per-codec - // 0x88 (which broke demux routing and muxed it silent). See docs/ifo.md. + // Regression (The Punisher 2004): audio[0]=AC-3, audio[1]=DTS. DTS at position 1 must get + // wire sub-id 0x89 (0x88|1), not the old per-codec 0x88 (which broke demux routing and + // muxed it silent). #[test] fn dts_after_ac3_uses_positional_substream_id() { let mut streams = vec![ @@ -1812,9 +1805,9 @@ mod tests { ); } - // Regression for freemkv#25 (NTSC chapter drift): real cell table vs - // reference chapter marks. Drift (0.1%, proportional to elapsed time) is - // why a short synthetic fixture wouldn't catch it. See docs/ifo.md. + // Regression for freemkv#25 (NTSC chapter drift): real cell table vs reference chapter + // marks. Drift (0.1%, proportional to elapsed time) is why a short synthetic fixture + // wouldn't catch it. #[test] fn pgc_chapter_times_ntsc_no_pulldown_drift() { // (minutes, seconds, frames) of NTSC non-drop-frame timecode per cell. @@ -2089,9 +2082,8 @@ mod tests { assert_eq!(title.feature_start_cell(), 1); } - // Regression: a crafted IFO whose program-map byte names a first_cell - // index larger than the actual cell count must NOT panic (it used to, - // slicing cell_durations out of bounds). See docs/ifo.md. + // Regression: a crafted IFO whose program-map byte names a first_cell index larger than the + // actual cell count must NOT panic (it used to, slicing cell_durations out of bounds). #[test] fn pgc_program_map_oob_cell_index_no_panic() { let mut pgc = vec![0u8; 0xEA]; @@ -2241,8 +2233,8 @@ mod tests { // ── PGC fixtures ───────────────────────────────────────────────────── - // Builds a standalone PGC at offset 0: header, program map (1-based first - // cell number per program), 24-byte cell table. See docs/ifo.md for the layout. + // Builds a standalone PGC at offset 0: header, program map (1-based first cell number per + // program), 24-byte cell table. fn build_pgc( pgc_time: [u8; 4], cells: &[(u8, [u8; 4], u32, u32)], @@ -2465,8 +2457,8 @@ mod tests { // ── PGCIT ──────────────────────────────────────────────────────────── - // Builds a VTS_PGCIT at offset 0: header, one 8-byte VTS_PGCI_SRP per PGC, - // then `pgcs` appended after the SRP table. See docs/ifo.md for the layout. + // Builds a VTS_PGCIT at offset 0: header, one 8-byte VTS_PGCI_SRP per PGC, then `pgcs` + // appended after the SRP table. fn build_pgcit(pgcs: &[Vec<u8>]) -> Vec<u8> { let mut d = vec![0u8; 8 + pgcs.len() * 8]; d[0..2].copy_from_slice(&(pgcs.len() as u16).to_be_bytes()); diff --git a/src/io/bounded.rs b/src/io/bounded.rs index a9878744..bc2345c8 100644 --- a/src/io/bounded.rs +++ b/src/io/bounded.rs @@ -3,10 +3,9 @@ //! cooperative [`Halt`] poll. The calling thread is never trapped //! inside a kernel call. //! -//! Escape hatch for syscalls that trap a thread inside the kernel -//! (`sync_file_range`, `fsync`, NFS writes) where a cooperative -//! [`Halt`] can't interrupt. The worker thread is leaked on timeout -//! or halt. See docs/bounded.md for rationale, trade-offs, and platform notes. +//! Escape hatch for syscalls that trap a thread inside the kernel (`sync_file_range`, `fsync`, +//! NFS writes) where a cooperative [`Halt`] can't interrupt. The worker thread is leaked on +//! timeout or halt. use std::sync::mpsc::{RecvTimeoutError, sync_channel}; use std::thread; @@ -34,9 +33,8 @@ pub(crate) enum BoundedError { WorkerLost, } -// Runs `op` on a worker thread with a deadline + optional [`Halt`] -// poll; returns Ok, or Err on Halted/Timeout/WorkerLost (worker leaked). -// See docs/bounded.md for halt polling, `op` ownership, and semantics. +// Runs `op` on a worker thread with a deadline + optional [`Halt`] poll; returns Ok, or Err on +// Halted/Timeout/WorkerLost (worker leaked). pub(crate) fn bounded_syscall<F, R>( halt: Option<&Halt>, timeout: Duration, diff --git a/src/io/byte_prefetcher.rs b/src/io/byte_prefetcher.rs index 2a28b316..c48f4949 100644 --- a/src/io/byte_prefetcher.rs +++ b/src/io/byte_prefetcher.rs @@ -1,11 +1,8 @@ -//! `BytePrefetcher` — `std::io::Read` analogue of -//! [`crate::sector::PrefetchedSectorSource`]. Spawns a producer -//! thread that fills a bounded pool of `Vec<u8>` chunks from the -//! underlying reader and ships them through a channel; the consumer -//! recycles emptied buffers back so the producer re-fills in place, -//! for zero allocations and zero cross-thread frees in the hot loop. -//! Works for any stream whose source is an `io::Read`, not just a -//! `SectorSource`. See docs/byte-prefetcher.md for the full note. +//! `BytePrefetcher` — `std::io::Read` analogue of [`crate::sector::PrefetchedSectorSource`]. +//! Spawns a producer thread that fills a bounded pool of `Vec<u8>` chunks from the underlying +//! reader and ships them through a channel; the consumer recycles emptied buffers back so the +//! producer re-fills in place, for zero allocations and zero cross-thread frees in the hot +//! loop. Works for any stream whose source is an `io::Read`, not just a `SectorSource`. use crate::halt::{Halt, POLL_INTERVAL}; use crossbeam_channel::{Receiver, RecvTimeoutError, SendTimeoutError, Sender, bounded}; @@ -207,9 +204,8 @@ impl Drop for BytePrefetcher { mod tests { use super::*; - // RECYCLE_DEPTH must be one MORE than FORWARD_DEPTH so the producer - // always has a spare buffer while the consumer holds the rest in - // flight. See docs/byte-prefetcher.md for the mutation rationale. + // RECYCLE_DEPTH must be one MORE than FORWARD_DEPTH so the producer always has a spare + // buffer while the consumer holds the rest in flight. #[test] fn recycle_depth_is_forward_depth_plus_one() { assert_eq!(RECYCLE_DEPTH, FORWARD_DEPTH + 1); @@ -252,9 +248,9 @@ mod tests { ); } - // CRITICAL regression: dropping the forward receiver + recycle sender - // after into_channels must let the producer see disconnection and - // exit, so dropping PrefetchShell (join) returns promptly (see docs/byte-prefetcher.md). + // CRITICAL regression: dropping the forward receiver + recycle sender after into_channels + // must let the producer see disconnection and exit, so dropping PrefetchShell (join) + // returns promptly. #[test] fn into_channels_drop_releases_producer() { within(10, || { @@ -315,9 +311,8 @@ mod tests { (out, err) } - // CORE CONTRACT: every source byte delivered in order, exactly once. - // 5000-byte source, 1024 chunk size forces multiple chunks; see - // docs/byte-prefetcher.md for the mutation this test grounds. + // CORE CONTRACT: every source byte delivered in order, exactly once. 5000-byte source, 1024 + // chunk size forces multiple chunks. #[test] fn delivers_all_bytes_in_order_across_chunks() { within(10, || { @@ -329,9 +324,8 @@ mod tests { }); } - // Short-read truncation: a reader returning fewer bytes than - // requested must not leave stale tail bytes; 10-byte source with a - // 4096 chunk must yield exactly 10 bytes (see docs/byte-prefetcher.md). + // Short-read truncation: a reader returning fewer bytes than requested must not leave stale + // tail bytes; 10-byte source with a 4096 chunk must yield exactly 10 bytes. #[test] fn short_read_truncates_to_actual_length() { within(10, || { @@ -365,9 +359,8 @@ mod tests { }); } - // Error propagation: a reader that fails mid-stream must surface the - // io::Error as an Err batch, not swallow it — one good chunk then - // the error (see docs/byte-prefetcher.md for the mutation grounded). + // Error propagation: a reader that fails mid-stream must surface the io::Error as an Err + // batch, not swallow it — one good chunk then the error. #[test] fn read_error_is_propagated_as_err_batch() { within(10, || { @@ -394,9 +387,8 @@ mod tests { }); } - // PANIC propagation: a reader that PANICS mid-stream must not read as a - // clean EOF; catch_unwind sends an explicit Err sentinel first (see - // docs/byte-prefetcher.md for why a bare drop of tx would be unsafe). + // PANIC propagation: a reader that PANICS mid-stream must not read as a clean EOF; + // catch_unwind sends an explicit Err sentinel first. #[test] fn read_panic_surfaces_as_err_batch_not_clean_eof() { within(10, || { @@ -426,9 +418,8 @@ mod tests { }); } - // Recycle-buffer reuse must not leak stale bytes between chunks of - // different lengths; source 8×0xAA + 3×0xBB, chunk_bytes=8 (see - // docs/byte-prefetcher.md for the regrow/truncate sequence detail). + // Recycle-buffer reuse must not leak stale bytes between chunks of different lengths; + // source 8×0xAA + 3×0xBB, chunk_bytes=8. #[test] fn recycled_buffer_carries_no_stale_tail() { within(10, || { @@ -469,9 +460,8 @@ mod tests { }); } - // Dropping BytePrefetcher directly (without into_channels) must join - // the producer cleanly on a finite source: EOF drops tx, Drop's join - // returns (see docs/byte-prefetcher.md for the mutation grounded). + // Dropping BytePrefetcher directly (without into_channels) must join the producer cleanly + // on a finite source: EOF drops tx, Drop's join returns. #[test] fn drop_finite_prefetcher_joins_cleanly() { within(10, || { @@ -483,9 +473,8 @@ mod tests { }); } - // Regression: dropping a BytePrefetcher directly with an ENDLESS - // source must not deadlock. Fix drops rx+recycle_tx BEFORE the join - // (see docs/byte-prefetcher.md for the old sibling-drop-order bug). + // Regression: dropping a BytePrefetcher directly with an ENDLESS source must not deadlock. + // Fix drops rx+recycle_tx BEFORE the join. #[test] fn drop_endless_prefetcher_joins_cleanly() { within(10, || { diff --git a/src/io/file_sector_source/linux.rs b/src/io/file_sector_source/linux.rs index 1ceaf68b..c63aa1b2 100644 --- a/src/io/file_sector_source/linux.rs +++ b/src/io/file_sector_source/linux.rs @@ -7,7 +7,6 @@ //! already-read pages from the page cache so a large streaming read //! doesn't fill memory and starve concurrent writes. Together they //! mirror the write-side WritebackPipeline's policy. -// See docs/file-sector-source-linux.md — rationale and regression history. use std::fs::File; use std::os::unix::io::AsRawFd; @@ -34,9 +33,8 @@ pub(crate) fn drop_window(file: &File, start: u64, len: u64) { } } -// Async-prefetch `len` bytes at `offset`: queues readahead(2) without -// waiting, so the next batch's I/O overlaps current-batch processing. -// See docs/file-sector-source-linux.md — why this beats kernel readahead alone. +// Async-prefetch `len` bytes at `offset`: queues readahead(2) without waiting, so the next +// batch's I/O overlaps current-batch processing. pub(crate) fn prefetch(file: &File, offset: u64, len: u64) { unsafe { libc::readahead(file.as_raw_fd(), offset as i64, len as usize); diff --git a/src/io/file_sector_source/macos.rs b/src/io/file_sector_source/macos.rs index 73503ddf..e2432428 100644 --- a/src/io/file_sector_source/macos.rs +++ b/src/io/file_sector_source/macos.rs @@ -1,13 +1,11 @@ -// macOS: hint the kernel to prefetch via fcntl(F_RDADVISE, &radvisory), -// the closest equivalent to POSIX_FADV_SEQUENTIAL. -// See docs/file-sector-source-macos.md — F_RDADVISE background. +// macOS: hint the kernel to prefetch via fcntl(F_RDADVISE, &radvisory), the closest equivalent +// to POSIX_FADV_SEQUENTIAL. use std::fs::File; use std::os::unix::io::AsRawFd; -// Cap on the byte length we pass to `F_RDADVISE`; 64 MiB is generous -// without over-asking the OS's cache. -// See docs/file-sector-source-macos.md — RDADVISE_MAX_BYTES rationale. +// Cap on the byte length we pass to `F_RDADVISE`; 64 MiB is generous without over-asking the +// OS's cache. const RDADVISE_MAX_BYTES: i64 = 64 * 1024 * 1024; pub(crate) fn hint_sequential(file: &File, len_bytes: u64) { @@ -22,14 +20,12 @@ pub(crate) fn hint_sequential(file: &File, len_bytes: u64) { } } -// No direct POSIX_FADV_DONTNEED equivalent; F_NOCACHE is too coarse -// (disables caching for the whole fd), so this is a best-effort no-op. -// See docs/file-sector-source-macos.md — drop_window rationale. +// No direct POSIX_FADV_DONTNEED equivalent; F_NOCACHE is too coarse (disables caching for the +// whole fd), so this is a best-effort no-op. pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {} -// Async-prefetch `[offset, offset+len)` via the same F_RDADVISE -// primitive as the open-time hint, targeted at a moving window. -// See docs/file-sector-source-macos.md — prefetch details. +// Async-prefetch `[offset, offset+len)` via the same F_RDADVISE primitive as the open-time +// hint, targeted at a moving window. pub(crate) fn prefetch(file: &File, offset: u64, len: u64) { let bytes = (len as i64).min(RDADVISE_MAX_BYTES); let mut ra = libc::radvisory { diff --git a/src/io/file_sector_source/mod.rs b/src/io/file_sector_source/mod.rs index a0ebd1d9..9ca48a22 100644 --- a/src/io/file_sector_source/mod.rs +++ b/src/io/file_sector_source/mod.rs @@ -5,9 +5,6 @@ //! Issues a platform "sequential access" hint on open, prefetches the //! next window after each read, and periodically evicts the consumed //! byte range via `posix_fadvise(DONTNEED)` to bound page-cache pressure. -//! -//! See docs/file-sector-source.md for design rationale (no app-level -//! buffer, DONTNEED window, platform hint, prefetch). #[cfg(target_os = "linux")] pub(crate) mod linux; @@ -39,14 +36,12 @@ use crate::sector::SectorSource; use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64}; -// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop; mirrors -// WRITEBACK_CHUNK_BYTES. 32 MiB is empirically tuned (7200rpm HDD/SATA); -// see docs/file-sector-source.md. Override: FREEMKV_READ_DROP_CHUNK_MIB. +// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop; mirrors WRITEBACK_CHUNK_BYTES. 32 +// MiB is empirically tuned (7200rpm HDD/SATA). const READ_DROP_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024; -// Max MiB from FREEMKV_READ_DROP_CHUNK_MIB: 64 GiB, and small enough -// n*1024*1024 can't overflow u64 (mirrors WRITEBACK_CHUNK_MIB_MAX). -// See docs/file-sector-source.md; out-of-range falls back to default. +// Max MiB from FREEMKV_READ_DROP_CHUNK_MIB: 64 GiB, and small enough n*1024*1024 can't overflow +// u64 (mirrors WRITEBACK_CHUNK_MIB_MAX). const READ_DROP_CHUNK_MIB_MAX: u64 = 64 * 1024; fn read_drop_chunk_bytes() -> u64 { @@ -190,9 +185,8 @@ mod tests { use std::io::Write; use tempfile::tempdir; - // Undersized `out` must error, not panic: `debug_assert!` alone is compiled - // out in release, letting `out[..bytes]` panic. See docs/file-sector-source.md - // (also guarded in Drive::read_fua and PrefetchedSectorSource). + // Undersized `out` must error, not panic: `debug_assert!` alone is compiled out in release, + // letting `out[..bytes]` panic. #[test] fn read_sectors_with_an_undersized_buffer_errors_rather_than_panicking() { let dir = tempdir().unwrap(); @@ -480,9 +474,8 @@ mod tests { assert_eq!(io.kind(), std::io::ErrorKind::NotFound); } - // A DONTNEED drop crossing the chunk threshold must not corrupt/short - // reads — it's an advisory hint. Reads past the default 32 MiB chunk - // (16384 sectors) so eviction fires; see docs/file-sector-source.md. + // A DONTNEED drop crossing the chunk threshold must not corrupt/short reads — it's an + // advisory hint. Reads past the default 32 MiB chunk (16384 sectors) so eviction fires. #[test] fn dontneed_eviction_does_not_affect_data() { // 32 MiB default chunk = 16384 sectors; read a bit past it. @@ -511,9 +504,8 @@ mod tests { } } - // FREEMKV_READ_DROP_CHUNK_MIB must be bounded before the MiB→byte - // multiply (mirrors WRITEBACK_CHUNK_MIB_MAX): unbounded, values above - // 2^44 overflow/wrap — see docs/file-sector-source.md. + // FREEMKV_READ_DROP_CHUNK_MIB must be bounded before the MiB→byte multiply (mirrors + // WRITEBACK_CHUNK_MIB_MAX): unbounded, values above 2^44 overflow/wrap. #[test] fn read_drop_chunk_env_is_bounded_before_the_multiply() { // Default when unset / zero / out of range. diff --git a/src/io/fsync/mod.rs b/src/io/fsync/mod.rs index 9cab832b..98cf7293 100644 --- a/src/io/fsync/mod.rs +++ b/src/io/fsync/mod.rs @@ -1,9 +1,8 @@ //! Platform-aware crash-durability primitives. //! -//! [`dir`] fsyncs a directory so a prior `rename(2)` into it is durable -//! (no-op on Windows); [`file_durable`] fsyncs a file's contents + metadata, -//! opened read+write so the flush also succeeds on Windows. See -//! docs/fsync.md for the full per-platform rationale. +//! [`dir`] fsyncs a directory so a prior `rename(2)` into it is durable (no-op on Windows); +//! [`file_durable`] fsyncs a file's contents + metadata, opened read+write so the flush also +//! succeeds on Windows. //! //! Per the crate convention (see [`crate::io::writeback_file`]), platform //! dispatch happens once here via cfg-gated `mod` decls — no inline `#[cfg]`. diff --git a/src/io/image_writer.rs b/src/io/image_writer.rs index 3982adfd..942803db 100644 --- a/src/io/image_writer.rs +++ b/src/io/image_writer.rs @@ -4,9 +4,8 @@ //! out to a file, in order, once. It is what an `iso://` DESTINATION means when //! the source is not a physical drive. //! -//! Drive sources go through `freemkv_engine::copy` (the recovery path) instead; -//! this function is for everything else. See docs/image-writer.md for why the -//! two paths must stay separate. +//! Drive sources go through `freemkv_engine::copy` (the recovery path) instead; this function +//! is for everything else. use crate::consts::SECTOR_BYTES; use crate::error::{Error, Result}; @@ -24,9 +23,8 @@ const BATCH_SECTORS: u32 = 2048; /// Write `total_sectors` sectors from `reader` to `dest`. /// /// Reads sequentially, writes in order; does no decryption itself (caller's call). -/// `on_progress` runs after each batch with cumulative bytes and must not block; -/// `halt` is checked once per batch, and on cancellation the partial file is kept. -/// Returns bytes written. See docs/image-writer.md for rationale. +/// `on_progress` runs after each batch with cumulative bytes and must not block; `halt` is +/// checked once per batch, and on cancellation the partial file is kept. Returns bytes written. /// /// # Errors /// diff --git a/src/io/mod.rs b/src/io/mod.rs index ff1ba457..282c5fd3 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -6,8 +6,6 @@ //! `Pipeline` + `Sink` overlaps reads with writes via a bounded //! channel + consumer thread. `byte_prefetcher` is the read-ahead //! producer feeding the mux pipeline for `io::Read`-backed sources. -//! -//! See docs/io-mod.md for rationale. pub(crate) mod bounded; pub mod byte_prefetcher; diff --git a/src/io/pipeline.rs b/src/io/pipeline.rs index 950bc5ac..172ed410 100644 --- a/src/io/pipeline.rs +++ b/src/io/pipeline.rs @@ -4,9 +4,6 @@ //! through a bounded `crossbeam_channel`, and joins it on `finish()`. //! The consumer's behaviour is supplied by a [`Sink`] implementation: //! `apply` is called once per item, `close` is called once at the end. -//! -//! See docs/pipeline.md for full cancellation/error semantics and the -//! `FREEMKV_DEBUG=1` debug-logging switch. use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; @@ -18,16 +15,14 @@ use crossbeam_channel::{Sender, TrySendError, bounded}; use crate::error::Error; use crate::halt::Halt; -// Deadline for finish_with_halt's polling join. See docs/pipeline.md — -// it's a backstop for a wedged kernel call, not a normal timeout. +// Deadline for finish_with_halt's polling join. pub const JOIN_TIMEOUT_SECS: u64 = 600; -// Grace period after a halt/timeout fires in finish_with_halt, to let a -// "nearly done" consumer join cleanly. See docs/pipeline.md. +// Grace period after a halt/timeout fires in finish_with_halt, to let a "nearly done" consumer +// join cleanly. const FINISH_GRACE_SECS: u64 = 5; // Halt-check cadence for the send loop; aliases crate::halt::POLL_INTERVAL. -// See docs/pipeline.md — SEND_HALT_CHECK_INTERVAL. use crate::halt::POLL_INTERVAL; const SEND_HALT_CHECK_INTERVAL: Duration = POLL_INTERVAL; @@ -43,9 +38,8 @@ pub fn debug_enabled() -> bool { }) } -// Converts a consumer-thread panic payload into Error::PipelineConsumerPanicked. -// The panic message is logged here for diagnostics, not baked into the error -// value. See docs/pipeline.md — consumer_panicked. +// Converts a consumer-thread panic payload into Error::PipelineConsumerPanicked. The panic +// message is logged here for diagnostics, not baked into the error value. fn consumer_panicked(payload: Box<dyn std::any::Any + Send>) -> Error { let msg = payload .downcast_ref::<&'static str>() @@ -61,9 +55,8 @@ fn consumer_panicked(payload: Box<dyn std::any::Any + Send>) -> Error { Error::PipelineConsumerPanicked } -// Consumer lifecycle state, shared between caller and consumer thread. Each -// transition is a compare-exchange out of RUNNING so abandon vs. finalise -// stays mutually exclusive. See docs/pipeline.md — `state` module. +// Consumer lifecycle state, shared between caller and consumer thread. Each transition is a +// compare-exchange out of RUNNING so abandon vs. finalise stays mutually exclusive. mod state { /// Consumer is running; neither side has committed yet. pub const RUNNING: u8 = 0; @@ -76,9 +69,8 @@ mod state { pub const CLOSING: u8 = 2; } -// Spin-polls handle.is_finished() for `grace` before accepting the thread -// leak, so a "nearly done" consumer still joins cleanly. See docs/pipeline.md -// — finish_with_grace. +// Spin-polls handle.is_finished() for `grace` before accepting the thread leak, so a "nearly +// done" consumer still joins cleanly. fn finish_with_grace<R: Send + 'static>( handle: thread::JoinHandle<Result<R, Error>>, state: &Arc<AtomicU8>, @@ -201,14 +193,12 @@ pub trait Sink<I>: Send + 'static { pub struct Pipeline<I: Send + 'static, R: Send + 'static> { tx: Sender<I>, handle: JoinHandle<Result<R, Error>>, - /// Set by [`finish_with_grace`] when the grace period expires and the - /// consumer thread is about to be leaked: it stops applying further - /// items and does NOT call `close()`, so a leaked consumer can't - /// finalise an output already reported as failed. One of - /// [`state::RUNNING`] / [`state::ABANDONED`] / [`state::CLOSING`]; - /// both transitions are compare-exchanges so abandoning and - /// finalising are mutually exclusive rather than racing. See - /// docs/pipeline.md for the full race this prevents. + /// Set by [`finish_with_grace`] when the grace period expires and the consumer thread is + /// about to be leaked: it stops applying further items and does NOT call `close()`, so a + /// leaked consumer can't finalise an output already reported as failed. One of + /// [`state::RUNNING`] / [`state::ABANDONED`] / [`state::CLOSING`]; both transitions are + /// compare-exchanges so abandoning and finalising are mutually exclusive rather than + /// racing. state: Arc<AtomicU8>, /// Set by the consumer the moment an `apply` returns `Err`, since the /// consumer keeps draining afterwards (so the producer never blocks @@ -221,12 +211,10 @@ pub struct Pipeline<I: Send + 'static, R: Send + 'static> { } impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { - /// Spawn the consumer thread with the given channel depth and - /// [`Sink`]. Named `freemkv-pipeline-consumer`; callers that want a - /// more specific name should use [`Pipeline::spawn_named`] instead. - /// Returns `Error::IoError` if the OS refuses the thread spawn - /// (resource exhaustion) rather than panicking. See docs/pipeline.md - /// for which in-crate callers use this vs. `spawn_named`. + /// Spawn the consumer thread with the given channel depth and [`Sink`]. Named + /// `freemkv-pipeline-consumer`; callers that want a more specific name should use + /// [`Pipeline::spawn_named`] instead. Returns `Error::IoError` if the OS refuses the thread + /// spawn (resource exhaustion) rather than panicking. pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> { Self::spawn_named("freemkv-pipeline-consumer", depth, sink) } @@ -477,10 +465,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { /// BLOCKS on consumer drain rather than polling, in slices of /// [`SEND_HALT_CHECK_INTERVAL`]. /// - /// Returns `Ok(())` once the item lands in the channel, or - /// `Err(item)` if the consumer disconnected, the halt fired, or the - /// deadline elapsed. NOT a `foo_with_X` variant of - /// [`Pipeline::send`] despite the name — see docs/pipeline.md. + /// Returns `Ok(())` once the item lands in the channel, or `Err(item)` if the consumer + /// disconnected, the halt fired, or the deadline elapsed. NOT a `foo_with_X` variant of + /// [`Pipeline::send`] despite the name. pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> { use crossbeam_channel::SendTimeoutError; let end = Instant::now() + deadline; @@ -569,9 +556,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { /// token and the [`JOIN_TIMEOUT_SECS`] deadline between slices. /// /// Returns `Ok(R)` on a clean exit, or one of [`Error::Halted`], - /// [`Error::PipelineJoinTimeout`], [`Error::PipelineConsumerPanicked`] - /// for the wedge cases (leaks the consumer after a grace spin). NOT - /// a `foo_with_X` variant of [`Pipeline::finish`] — see docs/pipeline.md. + /// [`Error::PipelineJoinTimeout`], [`Error::PipelineConsumerPanicked`] for the wedge cases + /// (leaks the consumer after a grace spin). NOT a `foo_with_X` variant of + /// [`Pipeline::finish`]. pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result<R, Error> { let Pipeline { tx, @@ -1036,7 +1023,6 @@ mod tests { // ── Added hardening tests ─────────────────────────────────────── // Zero items sent must still call close() exactly once. - // See docs/pipeline.md — empty_pipeline_still_calls_close. #[test] fn empty_pipeline_still_calls_close() { let close_called = Arc::new(AtomicUsize::new(0)); @@ -1058,7 +1044,6 @@ mod tests { } // close() returning Err must surface from finish(), not be swallowed. - // See docs/pipeline.md — close_error_propagates_from_finish. #[test] fn close_error_propagates_from_finish() { struct CloseFails; @@ -1077,8 +1062,7 @@ mod tests { assert!(matches!(res, Err(Error::DecryptFailed))); } - // try_send must report Full when saturated and the consumer is wedged, - // NOT block. See docs/pipeline.md — try_send_reports_full_when_saturated. + // try_send must report Full when saturated and the consumer is wedged, NOT block. #[test] fn try_send_reports_full_when_saturated() { let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -1105,8 +1089,7 @@ mod tests { let _ = pipe.finish(); } - // try_send must report Disconnected once the consumer has exited (via - // panic here). See docs/pipeline.md — try_send_reports_disconnected. + // try_send must report Disconnected once the consumer has exited (via panic here). #[test] fn try_send_reports_disconnected_after_consumer_gone() { let prev = std::panic::take_hook(); @@ -1135,8 +1118,7 @@ mod tests { ); } - // Plain send must hand the item back via Err(item) once the consumer - // has panicked. See docs/pipeline.md — send_returns_item_after_panic. + // Plain send must hand the item back via Err(item) once the consumer has panicked. #[test] fn send_returns_item_after_consumer_panicked() { let prev = std::panic::take_hook(); @@ -1161,8 +1143,7 @@ mod tests { ); } - // send_with_halt must return the exact item via Err(item) on the - // Disconnected arm. See docs/pipeline.md — send_with_halt_on_disconnect. + // send_with_halt must return the exact item via Err(item) on the Disconnected arm. #[test] fn send_with_halt_returns_item_on_disconnect() { let prev = std::panic::take_hook(); @@ -1205,9 +1186,8 @@ mod tests { assert_eq!(total, 0, "item was enqueued despite pre-cancelled halt"); } - // finish_with_halt(None) + wedged consumer must NOT spuriously return - // Halted (no halt supplied to observe). See docs/pipeline.md — - // finish_with_halt_none_does_not_spuriously_halt. + // finish_with_halt(None) + wedged consumer must NOT spuriously return Halted (no halt + // supplied to observe). #[test] fn finish_with_halt_none_does_not_spuriously_halt() { let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -1278,8 +1258,8 @@ mod tests { // ── Bug-fix regression tests ──────────────────────────────────────── - // Regression: halt fires but consumer finishes WITHIN the grace period — - // finish_with_halt must join cleanly and return Ok. See docs/pipeline.md. + // Regression: halt fires but consumer finishes WITHIN the grace period — finish_with_halt + // must join cleanly and return Ok. #[test] fn finish_with_halt_joins_cleanly_when_consumer_finishes_in_grace() { // A sink that adds a short artificial delay in `close` to @@ -1337,8 +1317,8 @@ mod tests { ); } - // Regression: a leaked consumer must not finalise an abandoned output — - // once released it must skip close(). See docs/pipeline.md. + // Regression: a leaked consumer must not finalise an abandoned output — once released it + // must skip close(). #[test] fn leaked_consumer_skips_close_after_abandonment() { struct WedgeThenRecord { @@ -1403,8 +1383,8 @@ mod tests { ); } - // Companion to the above: the abandonment guard must NOT fire when the - // consumer finishes inside the grace window. See docs/pipeline.md. + // Companion to the above: the abandonment guard must NOT fire when the consumer finishes + // inside the grace window. #[test] fn consumer_finishing_in_grace_still_calls_close() { struct ReleasableClose { @@ -1472,8 +1452,7 @@ mod tests { assert!(matches!(res, Ok(190)), "expected Ok(190), got {res:?}"); } - // A fatal apply error must become visible to the PRODUCER, not only to - // finish() — see docs/pipeline.md for the ENOSPC-on-60GB-mux scenario. + // A fatal apply error must become visible to the PRODUCER, not only to finish(). #[test] fn send_with_halt_fails_fast_once_apply_has_failed() { struct FailFirst { @@ -1528,8 +1507,8 @@ mod tests { ); } - // The abandon/finalise race: a consumer already committed to close() when - // grace expires must be waited for, not abandoned. See docs/pipeline.md. + // The abandon/finalise race: a consumer already committed to close() when grace expires + // must be waited for, not abandoned. #[test] fn abandon_loses_to_a_close_already_committed() { let state = Arc::new(AtomicU8::new(state::RUNNING)); diff --git a/src/io/platform_macos.rs b/src/io/platform_macos.rs index 8b531730..3c444363 100644 --- a/src/io/platform_macos.rs +++ b/src/io/platform_macos.rs @@ -1,9 +1,7 @@ //! Shared macOS `fcntl(F_PREALLOCATE)` definitions. //! -//! The `libc` crate doesn't expose these symbols across all macOS SDK -//! versions, so we define them locally with values from -//! `/usr/include/sys/fcntl.h`. See docs/platform-macos.md for why a -//! single source of truth here matters and how the cfg gate works. +//! The `libc` crate doesn't expose these symbols across all macOS SDK versions, so we define +//! them locally with values from `/usr/include/sys/fcntl.h`. //! //! [`crate::io::writeback_file`] and `crate::io::sink::preallocate` //! both depend on these constants and the `fstore_t` layout. diff --git a/src/io/sink/local_file.rs b/src/io/sink/local_file.rs index fa8efbcd..cd516c02 100644 --- a/src/io/sink/local_file.rs +++ b/src/io/sink/local_file.rs @@ -8,7 +8,6 @@ //! [`SequentialSink`](super::SequentialSink) is implemented explicitly //! so `finish()` flushes and `fsync`s even through a `dyn` trait object; //! [`RandomAccessSink`](super::RandomAccessSink) is over the `Seek` impl. -// See docs/local-file-sink.md — buffering & seek-flush rationale. use std::fs::{File, OpenOptions}; use std::io::{self, BufWriter, Seek, SeekFrom, Write}; diff --git a/src/io/sink/mod.rs b/src/io/sink/mod.rs index 1247ef40..56f392d1 100644 --- a/src/io/sink/mod.rs +++ b/src/io/sink/mod.rs @@ -1,12 +1,10 @@ //! Output-sink trait split for the buffering architecture. //! -//! [`SequentialSink`] — anything you can `Write` to in order (no seek; -//! sockets, pipes, M2TS/fMP4/HEVC-ES) — and [`RandomAccessSink`] — -//! `SequentialSink` plus a working `Seek` (local/NFS files; needed for -//! backpatch: MKV cluster sizes, Cues, MP4 moov-at-end). The muxer is -//! generic over whichever it requires, so muxing MKV to a socket is a -//! compile error. Buffering policy belongs to the concrete sink; see -//! docs/sink.md for the buffering-per-sink breakdown. +//! [`SequentialSink`] — anything you can `Write` to in order (no seek; sockets, pipes, +//! M2TS/fMP4/HEVC-ES) — and [`RandomAccessSink`] — `SequentialSink` plus a working `Seek` +//! (local/NFS files; needed for backpatch: MKV cluster sizes, Cues, MP4 moov-at-end). The muxer +//! is generic over whichever it requires, so muxing MKV to a socket is a compile error. +//! Buffering policy belongs to the concrete sink. use std::io::{Seek, Write}; @@ -21,11 +19,10 @@ pub use socket::{SocketSink, UdpSocketSink}; /// stores. No seek. Implementations own their write buffering — the /// trait does not impose or hide any buffering of its own. /// -/// `finish` drains any internal buffering and signals end-of-stream to -/// the transport (close-write on a socket, flush + fsync on a buffered -/// file, etc.). The default impl flushes via [`Write::flush`]; every -/// concrete sink here overrides it with its own finalisation. No -/// blanket `impl SequentialSink for T` — see docs/sink.md for why. +/// `finish` drains any internal buffering and signals end-of-stream to the transport +/// (close-write on a socket, flush + fsync on a buffered file, etc.). The default impl flushes +/// via [`Write::flush`]; every concrete sink here overrides it with its own finalisation. No +/// blanket `impl SequentialSink for T` pub trait SequentialSink: Write + Send { fn finish(&mut self) -> std::io::Result<()> { self.flush() @@ -37,9 +34,8 @@ pub trait SequentialSink: Write + Send { /// random-access sink is always usable as a sequential sink. pub trait RandomAccessSink: SequentialSink + Seek {} -// Picks the right RandomAccessSink impl for `dest` by filesystem type -// (Linux+NFS -> WritebackFile, else LocalFileSink). Not yet wired into -// mux::resolve; see docs/sink.md for the full picker/size_hint rationale. +// Picks the right RandomAccessSink impl for `dest` by filesystem type (Linux+NFS -> +// WritebackFile, else LocalFileSink). Not yet wired into mux::resolve. #[allow(dead_code)] pub(crate) fn open_for_mkv( dest: &std::path::Path, @@ -107,7 +103,7 @@ mod tests { assert_eq!(&bytes[..5], b"hello"); } - // Regression test for the silent-no-op finish() bug. See docs/sink.md. + // Regression test for the silent-no-op finish() bug. #[test] fn finish_through_trait_object_flushes_local_file() { let dir = tempfile::tempdir().unwrap(); @@ -130,8 +126,8 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - // Minimal SequentialSink that does NOT override finish(), so it - // exercises the trait's default impl. See docs/sink.md. + // Minimal SequentialSink that does NOT override finish(), so it exercises the trait's + // default impl. struct FlushTracker { flushed: Arc<AtomicBool>, bytes: Arc<AtomicUsize>, @@ -170,8 +166,7 @@ mod tests { assert_eq!(bytes.load(Ordering::SeqCst), 3); } - // Covers the `None` size_hint arm: must still be a random-access - // sink. See docs/sink.md. + // Covers the `None` size_hint arm: must still be a random-access sink. #[test] fn open_for_mkv_without_size_hint_is_random_access() { use std::io::{Seek, SeekFrom}; diff --git a/src/io/sink/socket.rs b/src/io/sink/socket.rs index f053fdbd..0bbd56dd 100644 --- a/src/io/sink/socket.rs +++ b/src/io/sink/socket.rs @@ -1,10 +1,9 @@ //! TCP / UDP socket sinks (sequential-only). //! -//! [`SocketSink`] wraps a `TcpStream` in a 1 MiB `BufWriter`; `finish()` -//! flushes then `shutdown(Write)`s for a clean end-of-stream. -//! [`UdpSocketSink`] wraps a connected `UdpSocket`; each `write` emits -//! one datagram and `finish()` is a no-op. Neither implements `Seek`, -//! so neither satisfies [`RandomAccessSink`]. See docs/socket.md. +//! [`SocketSink`] wraps a `TcpStream` in a 1 MiB `BufWriter`; `finish()` flushes then +//! `shutdown(Write)`s for a clean end-of-stream. [`UdpSocketSink`] wraps a connected +//! `UdpSocket`; each `write` emits one datagram and `finish()` is a no-op. Neither implements +//! `Seek`, so neither satisfies [`RandomAccessSink`]. //! //! [`SequentialSink`]: super::SequentialSink //! [`RandomAccessSink`]: super::RandomAccessSink @@ -14,9 +13,7 @@ use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs, UdpSocket}; use super::SequentialSink; -// `BufWriter` capacity for `SocketSink`. 1 MiB matches the typical -// kernel send-buffer ceiling; see docs/socket.md for the amplification -// rationale. +// `BufWriter` capacity for `SocketSink`. 1 MiB matches the typical kernel send-buffer ceiling. const TCP_BUF_CAPACITY: usize = 1024 * 1024; /// Sequential-only sink over a TCP connection. @@ -266,8 +263,8 @@ mod tests { // ── Added hardening tests ─────────────────────────────────────── - // `finish` must `shutdown(Write)` so the peer's `read_to_end` sees EOF - // instead of blocking forever. See docs/socket.md — test notes. + // `finish` must `shutdown(Write)` so the peer's `read_to_end` sees EOF instead of blocking + // forever. #[test] fn finish_signals_eof_to_peer() { use std::sync::mpsc; @@ -294,8 +291,7 @@ mod tests { assert_eq!(received, b"unflushed-tail"); } - // UDP `write` must emit one datagram per call, no coalescing. See - // docs/socket.md — test notes. + // UDP `write` must emit one datagram per call, no coalescing. #[test] fn udp_write_is_one_datagram_per_call() { let receiver = UdpSocket::bind("127.0.0.1:0").unwrap(); @@ -320,8 +316,7 @@ mod tests { assert!(buf[..second].iter().all(|&b| b == 0xBB)); } - // UDP `finish` is a documented no-op; must not error. See - // docs/socket.md — test notes. + // UDP `finish` is a documented no-op; must not error. #[test] fn udp_finish_is_noop_ok() { let receiver = UdpSocket::bind("127.0.0.1:0").unwrap(); diff --git a/src/io/writeback/linux.rs b/src/io/writeback/linux.rs index 4cce1da7..67e1e1b9 100644 --- a/src/io/writeback/linux.rs +++ b/src/io/writeback/linux.rs @@ -6,8 +6,6 @@ //! Chunk size adapts to storage speed from a rolling p95 of `WAIT_AFTER` //! latency, bounded to [4 MiB, 256 MiB]. NFS mounts, and any local storage //! that times out inside `WAIT_AFTER` (30s), skip the wait+dontneed step. -//! -//! See docs/writeback-linux.md for rationale, NFS handling, and timeout details. use std::collections::VecDeque; use std::fs::File; @@ -315,9 +313,8 @@ fn detect_nfs(fd: RawFd) -> bool { ) } -// Runs `sync_file_range(WAIT_AFTER)` on a worker thread, waiting up to -// WAIT_AFTER_TIMEOUT; `Some(elapsed_ms)` on success, `None` on timeout -// (worker leaked). See docs/writeback-linux.md — fd lifetime / fd-reuse safety. +// Runs `sync_file_range(WAIT_AFTER)` on a worker thread, waiting up to WAIT_AFTER_TIMEOUT; +// `Some(elapsed_ms)` on success, `None` on timeout (worker leaked). fn wait_after_with_timeout( worker_file: Option<File>, fallback_fd: RawFd, diff --git a/src/io/writeback_file/linux.rs b/src/io/writeback_file/linux.rs index 10501d12..3778d78d 100644 --- a/src/io/writeback_file/linux.rs +++ b/src/io/writeback_file/linux.rs @@ -29,9 +29,8 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) { ); } -// Bounded `fsync` (60 s deadline); a zero return is the only durability -// signal POSIX gives, so timeout/halt/lost-worker all map to `Err`. -// See docs/writeback-file-linux.md — durable_sync rationale & fd-reuse safety. +// Bounded `fsync` (60 s deadline); a zero return is the only durability signal POSIX gives, so +// timeout/halt/lost-worker all map to `Err`. pub(super) fn durable_sync(file: &File) -> io::Result<()> { // Clone so a leaked worker thread retains a valid fd even after the // original File is closed and its fd number is reused. @@ -72,9 +71,8 @@ mod tests { use super::*; use tempfile::NamedTempFile; - // Regression for the fd-reuse / use-after-close fix in `durable_sync`. - // Structural test only (the race is non-deterministic and not cleanly testable). - // See docs/writeback-file-linux.md — durable_sync_worker_uses_owned_clone_with_distinct_fd. + // Regression for the fd-reuse / use-after-close fix in `durable_sync`. Structural test only + // (the race is non-deterministic and not cleanly testable). #[test] fn durable_sync_worker_uses_owned_clone_with_distinct_fd() { let f = NamedTempFile::new().expect("tempfile create"); @@ -99,9 +97,8 @@ mod tests { } } -// Maps a bounded-fsync failure onto the `io::Error` `durable_sync` returns. -// Every arm means "no sync observably ran"; mirrors the macOS `F_FULLFSYNC` mapping. -// See docs/writeback-file-linux.md — bounded_failure_to_result rationale. +// Maps a bounded-fsync failure onto the `io::Error` `durable_sync` returns. Every arm means "no +// sync observably ran"; mirrors the macOS `F_FULLFSYNC` mapping. fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> { match e { crate::io::bounded::BoundedError::Timeout => { diff --git a/src/io/writeback_file/macos.rs b/src/io/writeback_file/macos.rs index 546c2c86..24909caa 100644 --- a/src/io/writeback_file/macos.rs +++ b/src/io/writeback_file/macos.rs @@ -5,8 +5,6 @@ //! - `durable_sync`: `fcntl(F_FULLFSYNC)` wrapped in //! [`crate::io::bounded::bounded_syscall`] with a 60 s deadline; falls //! back to plain `fsync` if unsupported. -//! -//! See docs/writeback-file-macos.md for the full rationale. use std::fs::File; use std::io; @@ -47,9 +45,8 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) { ); } -// fd-reuse safety: a leaked worker thread must not hit a recycled fd -// number, so we try_clone an owned File into the closure instead of a -// bare fd. See docs/writeback-file-macos.md. +// fd-reuse safety: a leaked worker thread must not hit a recycled fd number, so we try_clone an +// owned File into the closure instead of a bare fd. pub(super) fn durable_sync(file: &File) -> io::Result<()> { // Clone so a leaked worker thread retains a valid fd even after the // original File is closed and its fd number is reused. @@ -98,9 +95,8 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> { } } -// Maps a BoundedError onto the io::Error durable_sync returns. Every arm -// means no sync observably ran — never mapped to Ok. No message text -// (no user-facing English); see docs/writeback-file-macos.md. +// Maps a BoundedError onto the io::Error durable_sync returns. Every arm means no sync +// observably ran — never mapped to Ok. No message text (no user-facing English). fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> { match e { crate::io::bounded::BoundedError::Timeout => { @@ -133,9 +129,8 @@ mod tests { use super::*; use tempfile::NamedTempFile; - // Regression for the fd-reuse fix in durable_sync: try_clone succeeds - // and yields a distinct fd. The real race is non-deterministic; this - // structural check is the accepted substitute (docs/writeback-file-macos.md). + // Regression for the fd-reuse fix in durable_sync: try_clone succeeds and yields a distinct + // fd. The real race is non-deterministic; this structural check is the accepted substitute. #[test] fn durable_sync_worker_uses_owned_clone_with_distinct_fd() { let f = NamedTempFile::new().expect("tempfile create"); @@ -159,9 +154,8 @@ mod tests { durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile"); } - // Every BoundedError arm means no sync observably ran; asserted on the - // concrete ErrorKind/errno so a future arm reverting to Ok(()) fails - // here. See docs/writeback-file-macos.md. + // Every BoundedError arm means no sync observably ran; asserted on the concrete + // ErrorKind/errno so a future arm reverting to Ok(()) fails here. #[test] fn every_bounded_failure_is_reported_as_an_error() { use crate::io::bounded::BoundedError; diff --git a/src/io/writeback_file/mod.rs b/src/io/writeback_file/mod.rs index 4f550158..90abc694 100644 --- a/src/io/writeback_file/mod.rs +++ b/src/io/writeback_file/mod.rs @@ -1,11 +1,9 @@ //! `WritebackFile` — a `File` wrapper that drives a continuous -//! [`super::writeback::WritebackPipeline`] so large sequential writes -//! (sweep, patch, mux) don't accumulate unbounded dirty pages before a -//! stalling burst-flush. It implements `Write` and `Seek` so any call -//! site that wrote to a plain `File` can swap in `WritebackFile` -//! unchanged. Platform-specific preallocation/durable-flush primitives -//! live in per-OS sibling modules, dispatched via the cfg-gated `mod` -//! decls below — see docs/writeback-file.md for the full rationale. +//! [`super::writeback::WritebackPipeline`] so large sequential writes (sweep, patch, mux) don't +//! accumulate unbounded dirty pages before a stalling burst-flush. It implements `Write` and +//! `Seek` so any call site that wrote to a plain `File` can swap in `WritebackFile` unchanged. +//! Platform-specific preallocation/durable-flush primitives live in per-OS sibling modules, +//! dispatched via the cfg-gated `mod` decls below. #[cfg(target_os = "linux")] mod linux; @@ -92,12 +90,10 @@ impl WritebackFile { Self::new(file) } - /// Like [`Self::create`] but pre-reserves `size_bytes` of disk space - /// via the platform's extent-preallocation primitive (Linux - /// `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`; no-op on platforms - /// without one). The reported file size is unchanged — only the - /// on-disk extent allocation is preallocated. See - /// docs/writeback-file.md for rationale. + /// Like [`Self::create`] but pre-reserves `size_bytes` of disk space via the platform's + /// extent-preallocation primitive (Linux `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`; + /// no-op on platforms without one). The reported file size is unchanged — only the on-disk + /// extent allocation is preallocated. pub fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> { let file = File::create(path)?; platform::preallocate(&file, size_bytes); @@ -113,14 +109,11 @@ impl WritebackFile { Self::new(file) } - /// Drain in-flight writeback then issue a full fsync, in place of - /// `File::sync_all`. Bounded by - /// [`crate::io::bounded::bounded_syscall`] on Linux/macOS (60 s), so - /// a wedged NFS server cannot trap the caller indefinitely; - /// `Ok(())` is a durability barrier. Failure is - /// [`E_SYNC_TIMEOUT`](crate::error::E_SYNC_TIMEOUT), - /// [`E_HALTED`](crate::error::E_HALTED), or - /// [`E_SYNC_WORKER_LOST`](crate::error::E_SYNC_WORKER_LOST); see docs/writeback-file.md. + /// Drain in-flight writeback then issue a full fsync, in place of `File::sync_all`. Bounded + /// by [`crate::io::bounded::bounded_syscall`] on Linux/macOS (60 s), so a wedged NFS server + /// cannot trap the caller indefinitely; `Ok(())` is a durability barrier. Failure is + /// [`E_SYNC_TIMEOUT`](crate::error::E_SYNC_TIMEOUT), [`E_HALTED`](crate::error::E_HALTED), + /// or [`E_SYNC_WORKER_LOST`](crate::error::E_SYNC_WORKER_LOST). pub fn sync_all(&mut self) -> io::Result<()> { if self.seek_count > 0 { tracing::debug!( @@ -300,8 +293,8 @@ mod tests { // ── Added hardening tests ─────────────────────────────────────── - // `write` must return the inner File's reported count and advance - // `pos` by exactly that count (not `buf.len()`). See docs/writeback-file.md. + // `write` must return the inner File's reported count and advance `pos` by exactly that + // count (not `buf.len()`). #[test] fn write_returns_byte_count_and_advances_pos() { let dir = tempfile::tempdir().unwrap(); @@ -318,9 +311,8 @@ mod tests { assert_eq!(read_back(&p), b"twelve bytes"); } - // Redundant seek to the CURRENT position (sweep's `seek(Current(pos))` - // before every write) must not be treated as a boundary. See - // docs/writeback-file.md. + // Redundant seek to the CURRENT position (sweep's `seek(Current(pos))` before every write) + // must not be treated as a boundary. #[test] fn seek_to_current_position_is_noop_for_data() { let dir = tempfile::tempdir().unwrap(); @@ -340,8 +332,8 @@ mod tests { ); } - // `open` (no-truncate) must preserve existing file contents, distinct - // from `create`'s truncating path. See docs/writeback-file.md. + // `open` (no-truncate) must preserve existing file contents, distinct from `create`'s + // truncating path. #[test] fn open_preserves_existing_contents() { let dir = tempfile::tempdir().unwrap(); @@ -358,8 +350,8 @@ mod tests { assert_eq!(read_back(&p), b"PATCHED!-CONTENT"); } - // `new` queries stream_position() rather than hardcoding pos=0, so a - // non-zero starting offset stays in sync. See docs/writeback-file.md. + // `new` queries stream_position() rather than hardcoding pos=0, so a non-zero starting + // offset stays in sync. #[test] fn new_tracks_initial_position() { let dir = tempfile::tempdir().unwrap(); @@ -413,8 +405,8 @@ mod tests { assert_eq!(read_back(&p), b"01234567XY"); } - // `create_with_size_hint`'s hint reserves extents only; it must NOT - // pre-grow the logical file length. See docs/writeback-file.md. + // `create_with_size_hint`'s hint reserves extents only; it must NOT pre-grow the logical + // file length. #[test] fn create_with_size_hint_does_not_inflate_logical_length() { let dir = tempfile::tempdir().unwrap(); @@ -443,7 +435,7 @@ mod tests { } // Pins the WRITEBACK_CHUNK_* constants and the MiB->byte multiply that - // `writeback_chunk_bytes` relies on. See docs/writeback-file.md. + // `writeback_chunk_bytes` relies on. #[test] fn writeback_chunk_constants_and_conversion() { // Default is exactly 32 MiB. @@ -458,9 +450,8 @@ mod tests { ); } - // All `writeback_chunk_bytes` env-var branches in ONE test (avoids a - // data race between parallel tests mutating the same env var). See - // docs/writeback-file.md for the branch list. + // All `writeback_chunk_bytes` env-var branches in ONE test (avoids a data race between + // parallel tests mutating the same env var). #[test] fn writeback_chunk_env_override_branches() { // SAFETY: this is the only test touching this env var, and it diff --git a/src/io/writeback_file/windows.rs b/src/io/writeback_file/windows.rs index ea1723e9..18263a74 100644 --- a/src/io/writeback_file/windows.rs +++ b/src/io/writeback_file/windows.rs @@ -6,8 +6,6 @@ //! that keeps the reported size). //! - `durable_sync` delegates to `File::sync_all` (`FlushFileBuffers`), //! unbounded unlike the Linux/macOS impls. -//! -//! See docs/writeback-file-windows.md for the rationale. use std::fs::File; use std::io; diff --git a/src/keysource.rs b/src/keysource.rs index 3535e30d..047b919e 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -1,12 +1,11 @@ //! Key sources — the layer that hands libfreemkv a disc's terminal Unit Keys. //! -//! libfreemkv performs NO key lookup. An application resolves a disc's keys -//! through one or more [`KeySource`]s, each an adapter over a backing store -//! (a keydb file, a key server, the mapfile cache) that returns the disc's -//! terminal **Unit Keys** ([`crate::aacs::types::UnitKey`]), orchestrating -//! derivation via the [`ResolveCtx`] handed to it and libfreemkv's own crypto -//! primitives. libfreemkv owns the crypto; a source owns only PATH -//! ORCHESTRATION. See docs/keysource.md for the ownership split. +//! libfreemkv performs NO key lookup. An application resolves a disc's keys through one or more +//! [`KeySource`]s, each an adapter over a backing store (a keydb file, a key server, the +//! mapfile cache) that returns the disc's terminal **Unit Keys** +//! ([`crate::aacs::types::UnitKey`]), orchestrating derivation via the [`ResolveCtx`] handed to +//! it and libfreemkv's own crypto primitives. libfreemkv owns the crypto; a source owns only +//! PATH ORCHESTRATION. use crate::aacs::types::HostCert; use crate::aacs::types::{UnitKey, Vid}; @@ -15,28 +14,25 @@ use crate::error::Error; /// Minimum encrypted-content unit samples a single online key request must carry. /// -/// The key service identifies a key by which of the submitted units it decrypts, -/// so too few samples can return a key that matches an incidental unit rather -/// than the one asked about (a false positive); this many make a request -/// unambiguous. Canonical here so both `freemkv-keysources`'s online source and -/// libfreemkv's own FMTS forensic query ([`crate::mux`]) agree on one value — -/// see docs/keysource.md for the layering rationale. +/// The key service identifies a key by which of the submitted units it decrypts, so too few +/// samples can return a key that matches an incidental unit rather than the one asked about (a +/// false positive); this many make a request unambiguous. Canonical here so both +/// `freemkv-keysources`'s online source and libfreemkv's own FMTS forensic query +/// ([`crate::mux`]) agree on one value. pub const MIN_SAMPLE_UNITS: usize = 8; /// A set of encrypted content-unit samples PROVEN to carry at least /// [`MIN_SAMPLE_UNITS`] units — the online `/decode` request's proof-of-ownership. /// -/// "Parse, don't validate": the only constructor, [`DecodeSampleSet::new`], returns -/// `None` for an under-sized slice, so an online key request simply *cannot be built* -/// from too few samples — a compile-time obligation, not a runtime check a caller -/// can forget. See docs/keysource.md for the full rationale (including why the -/// wrapped samples get a hand-written, redacting [`Debug`] — the impl below). +/// "Parse, don't validate": the only constructor, [`DecodeSampleSet::new`], returns `None` for +/// an under-sized slice, so an online key request simply *cannot be built* from too few samples +/// — a compile-time obligation, not a runtime check a caller can forget. #[derive(Clone)] pub struct DecodeSampleSet(Vec<Vec<u8>>); impl std::fmt::Debug for DecodeSampleSet { - // Prints SHAPE only — a derived Debug dumped multi-MB of ciphertext - // verbatim into any log that formats it. See docs/keysource.md. + // Prints SHAPE only — a derived Debug dumped multi-MB of ciphertext verbatim into any log + // that formats it. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DecodeSampleSet") .field("units", &"<redacted>") @@ -107,9 +103,8 @@ pub struct DiscInputs { pub volume_label: Option<String>, } -// Redacting Debug: a derived impl used to print the Volume ID, the whole -// Unit_Key_RO.inf, the MKB and every ciphertext sample verbatim into a bug -// report's log. See docs/keysource.md. +// Redacting Debug: a derived impl used to print the Volume ID, the whole Unit_Key_RO.inf, the +// MKB and every ciphertext sample verbatim into a bug report's log. impl std::fmt::Debug for DiscInputs { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DiscInputs") @@ -179,8 +174,7 @@ impl<'a> DiscInputsCtx<'a> { /// stride, else 64-byte V20/V21) — the single source of truth, no separate /// version argument to drift from it. /// - /// A malformed `unit_key_ro` parses to an empty key set rather than an - /// error; see docs/keysource.md for why that's deliberate. + /// A malformed `unit_key_ro` parses to an empty key set rather than an error. pub fn new(inputs: &'a DiscInputs) -> Self { use crate::aacs::inf::parse_unit_key_ro; use crate::aacs::mkb::AacsVersion; @@ -256,12 +250,11 @@ pub struct UnitKeyResolution { /// A key source: an adapter over a backing store that resolves a disc's terminal /// Unit Keys. /// -/// Dumb about *policy*, smart about *its own material*: given a [`ResolveCtx`] a -/// source orchestrates derivation down to Unit Keys using the library's -/// boil-down crypto — never re-implementing AES. Two explicit resolve ops, one -/// per key kind: [`get_unit_keys`](Self::get_unit_keys) (base per-CPS-unit), -/// [`get_fmts_indexes`](Self::get_fmts_indexes) (AACS 2.1 forensic set). See -/// docs/keysource.md for the full contract. +/// Dumb about *policy*, smart about *its own material*: given a [`ResolveCtx`] a source +/// orchestrates derivation down to Unit Keys using the library's boil-down crypto — never +/// re-implementing AES. Two explicit resolve ops, one per key kind: +/// [`get_unit_keys`](Self::get_unit_keys) (base per-CPS-unit), +/// [`get_fmts_indexes`](Self::get_fmts_indexes) (AACS 2.1 forensic set). pub trait KeySource { /// Resolve this disc's base per-CPS-unit Unit Keys from this source. An empty /// `Vec` is a genuine "no key here"; `Err` is a source failure. @@ -328,11 +321,10 @@ pub fn resolve_and_apply( /// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for /// applications to render. ZERO English; the trace is typed enums only. /// -/// One-shot per source: each source's [`KeySource::get_unit_keys`] is called -/// exactly once; non-empty Unit Keys are applied via -/// [`crate::Disc::decrypt_with`], which validates against `inputs.samples` -/// and only mutates the disc on success. CPS-unit ORDER is load-bearing (see -/// docs/keysource.md) even though the carried number is not. +/// One-shot per source: each source's [`KeySource::get_unit_keys`] is called exactly once; +/// non-empty Unit Keys are applied via [`crate::Disc::decrypt_with`], which validates against +/// `inputs.samples` and only mutates the disc on success. CPS-unit ORDER is load-bearing even +/// though the carried number is not. pub fn resolve_and_apply_traced( sources: &[Box<dyn KeySource>], inputs: &DiscInputs, @@ -435,19 +427,17 @@ pub fn resolve_and_apply_traced( (false, trace) } -/// THE single key-fetch: drive `sources` in order and return the first non-empty -/// Unit Key set. Same sources, same call for both disc-open and read-miss -/// paths — there is no separate "fetch", only the samples in `ctx` differ. -/// Unlike [`resolve_and_apply`] this does not validate/commit to a disc — the -/// read's decorator re-decrypts with the returned keys, which is the -/// validation. See docs/keysource.md for the two callers. +/// THE single key-fetch: drive `sources` in order and return the first non-empty Unit Key set. +/// Same sources, same call for both disc-open and read-miss paths — there is no separate +/// "fetch", only the samples in `ctx` differ. Unlike [`resolve_and_apply`] this does not +/// validate/commit to a disc — the read's decorator re-decrypts with the returned keys, which +/// is the validation. pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> { drive_unit_keys(sources, ctx).keys } -// Whether a driver run resolved keys, and — if not — whether that's a genuine -// absence (`errored == false`, safe to cache) or a source FAILURE -// (`errored == true`, must not be cached). See docs/keysource.md. +// Whether a driver run resolved keys, and — if not — whether that's a genuine absence (`errored +// == false`, safe to cache) or a source FAILURE (`errored == true`, must not be cached). struct FetchOutcome { keys: Vec<UnitKey>, errored: bool, @@ -507,13 +497,12 @@ fn drive_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> F } } -/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS -/// inputs and a way to (re)build the application's key sources. The returned -/// resolver has the two explicit operations the mux and recovery decorator -/// call: [`unit_keys`](crate::sector::KeyFetch::unit_keys) drives -/// [`fetch_unit_keys`], [`fmts_indexes`](crate::sector::KeyFetch::fmts_indexes) -/// drives [`fetch_fmts_indexes`]. One builder shared by every read path and -/// consumer — see docs/keysource.md for the cloning/memoization contract. +/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS inputs and a way +/// to (re)build the application's key sources. The returned resolver has the two explicit +/// operations the mux and recovery decorator call: +/// [`unit_keys`](crate::sector::KeyFetch::unit_keys) drives [`fetch_unit_keys`], +/// [`fmts_indexes`](crate::sector::KeyFetch::fmts_indexes) drives [`fetch_fmts_indexes`]. One +/// builder shared by every read path and consumer. pub fn key_fetch( inputs: DiscInputs, make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>, @@ -568,13 +557,11 @@ pub fn key_fetch( crate::sector::KeyFetch::new(unit, fmts) } -/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no -/// decrypt) — the content samples that populate [`DiscInputs::samples`] for a -/// key server to validate a candidate against, and that [`resolve_and_apply`] -/// hands to [`crate::Disc::decrypt_with`]. Lives in the library, not a -/// key-source crate: carving units is decryption *mechanism*. "Encrypted" is -/// the AACS CPI (`buf[0] & 0xc0`), NOT the `is_clean` TS-sync heuristic — see -/// docs/keysource.md for why, and the extent-probing strategy. +/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no decrypt) — the +/// content samples that populate [`DiscInputs::samples`] for a key server to validate a +/// candidate against, and that [`resolve_and_apply`] hands to [`crate::Disc::decrypt_with`]. +/// Lives in the library, not a key-source crate: carving units is decryption *mechanism*. +/// "Encrypted" is the AACS CPI (`buf[0] & 0xc0`), NOT the `is_clean` TS-sync heuristic. pub fn read_encrypted_units( reader: &mut dyn crate::sector::SectorSource, title: &crate::disc::DiscTitle, @@ -681,9 +668,8 @@ mod tests { // ── KeySource default-method behaviour ──────────────────────────────────── - /// KeySource::host_certs() defaults to empty regardless of the MKB argument. - /// Mutation guard: a non-empty default would inject phantom certs into the - /// OEM handshake. See docs/keysource.md. + /// KeySource::host_certs() defaults to empty regardless of the MKB argument. Mutation + /// guard: a non-empty default would inject phantom certs into the OEM handshake. #[test] fn key_source_host_certs_defaults_to_empty() { struct MinimalSource; @@ -940,9 +926,8 @@ mod tests { ); } - /// A transient source outage must NOT be memoized as "no key" — a fingerprint - /// whose first fetch errored must be re-asked once recovered. Regression - /// guard; see docs/keysource.md. + /// A transient source outage must NOT be memoized as "no key" — a fingerprint whose first + /// fetch errored must be re-asked once recovered. Regression guard. #[test] fn errored_empty_is_not_cached_and_retries_when_source_recovers() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1029,9 +1014,9 @@ mod tests { ); } - /// The two `KeyFetch` operations route to the two DISTINCT trait methods - /// (`unit_keys` → `get_unit_keys`, `fmts_indexes` → `get_fmts_indexes`), not - /// an overload keyed on return length. See docs/keysource.md. + /// The two `KeyFetch` operations route to the two DISTINCT trait methods (`unit_keys` → + /// `get_unit_keys`, `fmts_indexes` → `get_fmts_indexes`), not an overload keyed on return + /// length. #[test] fn key_fetch_routes_unit_and_fmts_to_distinct_source_methods() { const BASE: [u8; 16] = [0xB0; 16]; @@ -1106,9 +1091,8 @@ mod tests { assert_eq!(got[0].key, [0x77; 16], "the base-only source is skipped"); } - /// #4 regression: content NOT at the extent midpoint (late-starting, or - /// midpoint in clear nav) must still be sampled. The old midpoint-forward - /// sampler returned empty; see docs/keysource.md. + /// #4 regression: content NOT at the extent midpoint (late-starting, or midpoint in clear + /// nav) must still be sampled. The old midpoint-forward sampler returned empty. #[test] fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() { use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; @@ -1188,9 +1172,9 @@ mod tests { } } - /// DISCRIMINATING: selection is by AACS CPI (byte 0), NOT TS-sync clarity — - /// half the units lack TS syncs but are CPI-clear (genuinely unencrypted). - /// Must return ONLY CPI-flagged units. See docs/keysource.md. + /// DISCRIMINATING: selection is by AACS CPI (byte 0), NOT TS-sync clarity — half the units + /// lack TS syncs but are CPI-clear (genuinely unencrypted). Must return ONLY CPI-flagged + /// units. #[test] fn read_encrypted_units_selects_by_cpi_not_ts_sync() { use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; @@ -1272,9 +1256,8 @@ mod tests { } } - /// Audit #5 — DISCRIMINATING test for the version→stride fix: a 2-key - /// `Unit_Key_RO.inf` whose 2nd key sits at the V20 offset, so a V10 parse - /// reads a DIFFERENT region. See docs/keysource.md. + /// Audit #5 — DISCRIMINATING test for the version→stride fix: a 2-key `Unit_Key_RO.inf` + /// whose 2nd key sits at the V20 offset, so a V10 parse reads a DIFFERENT region. #[test] fn disc_inputs_ctx_parses_unit_keys_at_the_version_stride() { use crate::aacs::mkb::{AACS_MAJOR_BD, AACS_MAJOR_UHD}; @@ -1348,9 +1331,9 @@ mod tests { assert!(dbg.contains("TITLE_2024"), "{dbg}"); } - /// `DecodeSampleSet` wraps the same on-disc ciphertext `DiscInputs` redacts; - /// a derived `Debug` would dump it verbatim. Sentinel 0xD5 = 213, matching - /// the `DiscInputs` test above. See docs/keysource.md. + /// `DecodeSampleSet` wraps the same on-disc ciphertext `DiscInputs` redacts; a derived + /// `Debug` would dump it verbatim. Sentinel 0xD5 = 213, matching the `DiscInputs` test + /// above. #[test] fn decode_sample_set_debug_is_redacted() { let set = DecodeSampleSet::new(vec![vec![0xD5; 6144]; MIN_SAMPLE_UNITS]) diff --git a/src/labels/bdj_feature.rs b/src/labels/bdj_feature.rs index 9a09ea28..22b98d55 100644 --- a/src/labels/bdj_feature.rs +++ b/src/labels/bdj_feature.rs @@ -1,28 +1,10 @@ -//! Generic BD-J feature-playlist resolver — the issue #45 "menu-walk" -//! authoritative layer, run when no framework parser surfaced a manifest hint. +//! Resolve a feature playlist from BD-J resources when framework parsers have no hint. //! -//! Two tiers, most reliable first: -//! -//! * **Tier 1 — jar-space resource sweep (High confidence).** Newer discs embed -//! the same `dcx.xml` / `playlists.xml` / `*.properties` manifests that Fox -//! and Paramount ship loose, but INSIDE a `/BDMV/JAR/*.jar` instead. The -//! feature id is still statically recoverable: reuse -//! [`fox::feature_hint`](super::fox::feature_hint) / -//! [`paramount::feature_hint_from_xml`](super::paramount::feature_hint_from_xml). -//! -//! * **Tier 2 — autostart-Xlet locator scan (best-effort, Medium).** Parse the -//! `/BDMV/BDJO/*.bdjo` Application Management Table for the AUTOSTART app's -//! jar(s) ([`crate::bdnav::bdjo`]), harvest `NNNNN.mpls` / `bd://…PLAYLIST:N` -//! String constants from that jar's `.class` constant pools, intersect them -//! with the real `/BDMV/PLAYLIST/*.mpls` set, and disambiguate by parsed -//! duration — the feature is a single candidate that dominates the runner-up -//! and carries multiple audio streams. A hint is emitted ONLY when a single -//! dominant candidate survives; integer-only evidence never emits. -//! -//! Everything failing here (modern-Fox pure-bytecode `StandardMenuXlet` with no -//! usable `.mpls` string, no usable integer constant) is left to the chapter -//! failsafe in `disc::mod`. This module is read-only, bounded, and never panics -//! (the caller also wraps it in `catch_unwind`). +//! Embedded XML/properties manifests take precedence. Otherwise, scan autostart +//! Xlet jars for playlist string constants and intersect them with actual playlists. +//! Emit a hint only for one dominant-duration candidate with multiple audio streams; +//! integer constants alone are insufficient evidence. Return `None` on ambiguity +//! so the caller can use its chapter-based fallback. use super::class_reader::CpInfo; use super::{FeaturePlaylistHint, fox, jar, paramount}; diff --git a/src/labels/bdmt.rs b/src/labels/bdmt.rs index 97748905..437b275c 100644 --- a/src/labels/bdmt.rs +++ b/src/labels/bdmt.rs @@ -6,17 +6,14 @@ //! ([`detect`] then [`parse`]); [`DiscMetadata`] is re-exported there. //! Extraction is best-effort — a malformed file yields `None`, other //! sibling-language files can still supply metadata. -//! -//! See docs/bdmt.md — schema fields, vendor quirks, real-world irregularities. use super::xml; use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::BTreeMap; -// Upper bound on a single bdmt_<lang>.xml we will read. Size is -// attacker-controlled UDF metadata; real files are a few KB. See -// docs/bdmt.md — MAX_BDMT_BYTES. +// Upper bound on a single bdmt_<lang>.xml we will read. Size is attacker-controlled UDF +// metadata; real files are a few KB. const MAX_BDMT_BYTES: u64 = 1024 * 1024; /// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`. @@ -131,9 +128,8 @@ fn lang_code_from_filename(name: &str) -> Option<String> { /// 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 into (title, description?, disc_set?). -// None if no title could be located. See docs/bdmt.md — parse_bdmt_xml -// title-element priority order. +// Parse one bdmt_<lang>.xml document into (title, description?, disc_set?). None if no title +// could be located. pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option<BdmtFields> { let title = extract_title(xml_text)?; // xml::text already returns a trimmed string (see xml::text), so the @@ -145,14 +141,12 @@ pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option<BdmtFields> { Some((title, description, disc_set)) } -/// Reject description candidates that are themselves XML fragments (e.g. -/// only <di:thumbnail/> children, no prose) OR mixed content — prose with an -/// embedded child element, which `xml::text` returns verbatim (tags and all). -/// The old `starts_with('<')` only caught fragments that LED with a tag, so a -/// description like `Real prose <di:thumbnail/>` leaked its markup through. Any -/// `<` that begins an element / close tag / comment / PI marks the value as +/// Reject description candidates that are themselves XML fragments (e.g. only <di:thumbnail/> +/// children, no prose) OR mixed content — prose with an embedded child element, which +/// `xml::text` returns verbatim (tags and all). The old `starts_with('<')` only caught +/// fragments that LED with a tag, so a description like `Real prose <di:thumbnail/>` leaked its +/// markup through. Any `<` that begins an element / close tag / comment / PI marks the value as /// XML-tainted; a bare `<` used as prose (e.g. `a < b`) is left alone. -/// See docs/bdmt.md — looks_like_xml. fn looks_like_xml(s: &str) -> bool { let bytes = s.as_bytes(); bytes.iter().enumerate().any(|(i, &b)| { diff --git a/src/labels/class_reader.rs b/src/labels/class_reader.rs index 7ba9ce02..ade71bf0 100644 --- a/src/labels/class_reader.rs +++ b/src/labels/class_reader.rs @@ -5,8 +5,8 @@ //! pool, methods, the `Code` attribute, and a non-allocating bytecode //! iterator. //! -//! No external deps beyond `std`. No `unsafe`. No panics on malformed -//! input — every parse fault is a typed [`Error`]. See docs/class-reader.md. +//! No external deps beyond `std`. No `unsafe`. No panics on malformed input — every parse fault +//! is a typed [`Error`]. // Foundation module — public API is staged for `labels::deluxe` (bytecode // walker) and `labels::dbp`'s refactor onto the constant-pool iterator. @@ -100,9 +100,9 @@ pub struct ConstantPool { } impl ConstantPool { - // Test-only: build a pool directly from entries (real code goes through - // ClassFile::parse). Caller must prepend CpInfo::Empty at index 0 and - // after each Long/Double entry (2-slot quirk). See docs/class-reader.md. + // Test-only: build a pool directly from entries (real code goes through ClassFile::parse). + // Caller must prepend CpInfo::Empty at index 0 and after each Long/Double entry (2-slot + // quirk). #[cfg(test)] pub(crate) fn from_entries(entries: Vec<CpInfo>) -> Self { ConstantPool { entries } @@ -436,9 +436,9 @@ fn read_constant_pool(r: &mut Reader<'_>) -> Result<ConstantPool> { Ok(ConstantPool { entries }) } -// Decode JVM "modified UTF-8" (JVMS §4.4.7): like UTF-8 but U+0000 is 0xC0 -// 0x80, and supplementary chars use a 3-byte-surrogate-pair encoding we -// don't bother handling — no label string needs it. See docs/class-reader.md. +// Decode JVM "modified UTF-8" (JVMS §4.4.7): like UTF-8 but U+0000 is 0xC0 0x80, and +// supplementary chars use a 3-byte-surrogate-pair encoding we don't bother handling — no label +// string needs it. fn decode_modified_utf8(bytes: &[u8]) -> std::result::Result<String, ()> { let mut out = String::with_capacity(bytes.len()); let mut i = 0; diff --git a/src/labels/clpi_audit.rs b/src/labels/clpi_audit.rs index c65f24ad..aa44ecc9 100644 --- a/src/labels/clpi_audit.rs +++ b/src/labels/clpi_audit.rs @@ -1,10 +1,8 @@ //! CLPI vs MPLS cross-validation diagnostic. //! -//! Walks per-clip CLPI program info and per-playlist MPLS STN tables, -//! dedups streams by PID alone (keeping the first `(coding_type, language)` -//! seen per source), then compares those fields to classify each PID as -//! CLPI-only, MPLS-only, Match, or Divergent. See -//! docs/clpi-audit.md for the full classification rules. +//! Walks per-clip CLPI program info and per-playlist MPLS STN tables, dedups streams by PID +//! alone (keeping the first `(coding_type, language)` seen per source), then compares those +//! fields to classify each PID as CLPI-only, MPLS-only, Match, or Divergent. //! //! [`audit`] returns [`ClpiVsMplsAudit`]; diagnostic only, not used by //! label selection. diff --git a/src/labels/criterion.rs b/src/labels/criterion.rs index 4eba9665..7c001d77 100644 --- a/src/labels/criterion.rs +++ b/src/labels/criterion.rs @@ -66,9 +66,8 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> Some(ParseResult::high(labels)) } -// Assign a 1-based stream number per StreamInfo. Map-assigned numbers win; -// unmapped streams get the next free per-type number, skipping map claims so -// the two domains never collide. See docs/criterion.md — assign_stream_numbers. +// Assign a 1-based stream number per StreamInfo. Map-assigned numbers win; unmapped streams get +// the next free per-type number, skipping map claims so the two domains never collide. fn assign_stream_numbers( infos: &[StreamInfo], stream_map: &HashMap<String, u16>, @@ -251,9 +250,8 @@ mod tests { assert_eq!(nums, vec![1, 2, 1]); } - // Immunity pin: an unusable/malformed element still occupies its slot - // (never dropped) and a close-less element can only shorten the list, - // never extend it. See docs/criterion.md — immunity pin, section-boundary half. + // Immunity pin: an unusable/malformed element still occupies its slot (never dropped) and a + // close-less element can only shorten the list, never extend it. #[test] fn an_unterminated_stream_element_shortens_the_list_it_cannot_extend_it() { let sp = concat!( @@ -432,9 +430,8 @@ mod tests { assert_eq!(nums[1], 2); } - // Crafted input drives the fallback counter to the top of the u16 space; - // must TERMINATE (not hang) and fail closed with None. Run on a worker - // thread with a deadline. See docs/criterion.md — exhausted_numbering_terminates_instead_of_looping. + // Crafted input drives the fallback counter to the top of the u16 space; must TERMINATE + // (not hang) and fail closed with None. Run on a worker thread with a deadline. #[test] fn exhausted_numbering_terminates_instead_of_looping() { let (tx, rx) = std::sync::mpsc::channel(); @@ -465,9 +462,8 @@ mod tests { } } - /// The whole 1-based u16 space must remain usable: 65535 unmapped audio - /// streams get 65535 distinct numbers with no panic and no wrap. See - /// docs/criterion.md — full_u16_numbering_space_is_usable_and_unique. + /// The whole 1-based u16 space must remain usable: 65535 unmapped audio streams get 65535 + /// distinct numbers with no panic and no wrap. #[test] fn full_u16_numbering_space_is_usable_and_unique() { let infos: Vec<StreamInfo> = (0..65_535u32) diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index fab619bb..bc2ae6aa 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -490,9 +490,6 @@ mod tests { assert_eq!(labels[0].qualifier, LabelQualifier::Forced); } - // See docs/ctrm.md — immunity pin: unlike `paramount`'s forced_sub - // flag, forced-ness is the row's stream-kind token, so a full - // dialogue track can never acquire it (Forced qualifier misuse). #[test] fn a_full_subtitle_track_kind_can_never_carry_the_forced_qualifier() { // Every subtitle kind in the vocabulary, one row each. @@ -562,9 +559,6 @@ mod tests { assert!(labels.is_empty()); } - // See docs/ctrm.md — immunity pin: stream number comes from field - // 3, so a dropped row never shifts later rows (unlike a - // positional counter). Mutation: number via a running counter. #[test] fn ls_stream_numbers_come_from_the_row_not_a_counter() { let labels = parse_language_streams_text( diff --git a/src/labels/dbp.rs b/src/labels/dbp.rs index 5c38b5d3..3e0cd732 100644 --- a/src/labels/dbp.rs +++ b/src/labels/dbp.rs @@ -11,9 +11,8 @@ //! ATextField,Subtitle0,None,Fontstrip_Composite,... //! ``` //! -//! The parser ignores any prefix before `TextField,`; `Subtitle0` is the -//! disable-subtitles button, skipped. Classification lives in -//! [`super::vocab`]. See docs/dbp.md for the implementation approach. +//! The parser ignores any prefix before `TextField,`; `Subtitle0` is the disable-subtitles +//! button, skipped. Classification lives in [`super::vocab`]. use super::class_reader::CpInfo; use super::{ParseResult, StreamLabel, StreamLabelType, jar, vocab}; @@ -77,14 +76,12 @@ fn scan_jar(archive: &mut jar::Jar) -> Vec<StreamLabel> { out } -// Cap on bytes retained per stream label. CONSTANT_Utf8_info's `length` is a -// u16 (JVMS §4.4.7), so a crafted constant could contribute up to 65535 -// bytes. See docs/dbp.md — MAX_LABEL_BYTES rationale. +// Cap on bytes retained per stream label. CONSTANT_Utf8_info's `length` is a u16 (JVMS §4.4.7), +// so a crafted constant could contribute up to 65535 bytes. const MAX_LABEL_BYTES: usize = 256; -// Cap on retained stream slots per type. Keys come from parse::<u16> on disc -// bytes, so all 65536 slots per type are reachable. See docs/dbp.md — -// MAX_LABELS_PER_TYPE rationale. +// Cap on retained stream slots per type. Keys come from parse::<u16> on disc bytes, so all +// 65536 slots per type are reachable. const MAX_LABELS_PER_TYPE: usize = 512; /// Record `label` for stream `n`, honouring the retention caps. Existing diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs index 3c5e6adb..efd7a8fc 100644 --- a/src/labels/deluxe.rs +++ b/src/labels/deluxe.rs @@ -3,10 +3,8 @@ //! Detected on discs whose `/BDMV/JAR/<x>.jar` contains a //! `com/bydeluxe/` directory entry. //! -//! Stream labels are ordinal references into enum classes obfuscated -//! per-disc, so parsing matches each enum's `<clinit>` shape rather -//! than class names; see docs/deluxe.md for the enum fingerprints, -//! decoder phases, confidence semantics, and confirmed studio variants. +//! Stream labels are ordinal references into enum classes obfuscated per-disc, so parsing +//! matches each enum's `<clinit>` shape rather than class names. use super::class_reader::{ AASTORE, ANEWARRAY, BIPUSH, ClassFile, CodeAttribute, ConstantPool, CpInfo, GETSTATIC, @@ -261,21 +259,20 @@ const FINGERPRINTS: &[Fingerprint] = &[ /// prefix, so a count mismatch within tolerance is informative-but-OK. const LDC_COUNT_TOLERANCE: usize = 4; -// Cap on `ldc` operands retained per class by clinit_ldc_strings. The bound -// must be on decompressed work, not disc-file size (deflate can inflate a -// small crafted class). See docs/deluxe.md "Size caps" for headroom math. +// Cap on `ldc` operands retained per class by clinit_ldc_strings. The bound must be on +// decompressed work, not disc-file size (deflate can inflate a small crafted class). const MAX_CLINIT_LDC_STRINGS: usize = 4096; -// Companion byte cap for MAX_CLINIT_LDC_STRINGS: guards against repeated ldc -// of one huge Utf8 constant (up to 64 KiB each). See docs/deluxe.md. +// Companion byte cap for MAX_CLINIT_LDC_STRINGS: guards against repeated ldc of one huge Utf8 +// constant (up to 64 KiB each). const MAX_CLINIT_LDC_BYTES: usize = 256 * 1024; -// Aggregate companion to MAX_CLINIT_LDC_BYTES: bounds retention across ALL -// classes at once, not just per class. See docs/deluxe.md "Size caps". +// Aggregate companion to MAX_CLINIT_LDC_BYTES: bounds retention across ALL classes at once, not +// just per class. const MAX_CANDIDATE_TOTAL_BYTES: usize = 16 * 1024 * 1024; -// Entry-count companion to MAX_CANDIDATE_TOTAL_BYTES, guarding per-entry -// HashMap/String overhead the byte budget alone doesn't count. See docs/deluxe.md. +// Entry-count companion to MAX_CANDIDATE_TOTAL_BYTES, guarding per-entry HashMap/String +// overhead the byte budget alone doesn't count. const MAX_CANDIDATE_CLASSES: usize = 65536; /// The Phase A candidate pool: every class's retained `<clinit>` ldc strings, @@ -397,9 +394,8 @@ fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str, MasterEnu .collect() } -// Ordinal -> putstatic field-name mapping (mirrors clinit_ldc_strings' -// ordinal -> value), so MasterEnumTable can resolve a binding class's -// `getstatic E.<field>` back to an ordinal. See docs/deluxe.md. +// Ordinal -> putstatic field-name mapping (mirrors clinit_ldc_strings' ordinal -> value), so +// MasterEnumTable can resolve a binding class's `getstatic E.<field>` back to an ordinal. fn clinit_enum_field_names(class: &super::class_reader::ClassFile) -> Vec<String> { let mut out: Vec<String> = Vec::new(); let mut out_bytes = 0usize; @@ -602,7 +598,6 @@ const BD_CODING_TYPE_CLASS: &str = "org/bluray/ti/CodingType"; // Cap on Constructions retained from a binding `<clinit>` walk (also bounds // decode_binding_class's per-class union and parse's cross-class union). -// See docs/deluxe.md "Size caps" for the headroom math. const MAX_CONSTRUCTIONS: usize = 4096; /// Phase D entry point: find the binding class in `archive`, run the @@ -688,9 +683,9 @@ impl<'a> BindingDecoder<'a> { } } - // Pushes onto the symbolic stack, honouring the declared max_stack: JVMS - // 4.7.3 forbids exceeding it, so a push past it means unverifiable - // bytecode. Bounds the decoder against a crafted class. See docs/deluxe.md. + // Pushes onto the symbolic stack, honouring the declared max_stack: JVMS 4.7.3 forbids + // exceeding it, so a push past it means unverifiable bytecode. Bounds the decoder against a + // crafted class. fn push(&mut self, val: StackVal) { if self.stack.len() >= self.max_stack { return; @@ -973,8 +968,8 @@ impl MasterEnumTable { // ── interpret_streams: Constructions → StreamLabels ───────────────────────── -// Converts Phase D's per-construction tuples into StreamLabels (args -// identified by TYPE not position). See docs/deluxe.md. +// Converts Phase D's per-construction tuples into StreamLabels (args identified by TYPE not +// position). fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -> Vec<StreamLabel> { let mut audio_idx: u16 = 0; let mut sub_idx: u16 = 0; @@ -1097,9 +1092,8 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - out } -// Which stream list each binding type enumerates, learned from the -// constructions that DID resolve a language; a type that resolved as both -// kinds is left out (no consistent answer). See docs/deluxe.md. +// Which stream list each binding type enumerates, learned from the constructions that DID +// resolve a language; a type that resolved as both kinds is left out (no consistent answer). fn slot_kinds(constructions: &[Construction]) -> HashMap<&str, Option<StreamLabelType>> { let mut kinds: HashMap<&str, Option<StreamLabelType>> = HashMap::new(); for c in constructions { diff --git a/src/labels/fox.rs b/src/labels/fox.rs index c1707d0b..c0a96e13 100644 --- a/src/labels/fox.rs +++ b/src/labels/fox.rs @@ -1,11 +1,9 @@ //! Fox — loose `/BDMV/JAR/<id>/dcx.xml` plain-XML manifest. //! -//! Root `<dcx><disc>` lists playlists; the feature playlist's nested -//! `<audio>`/`<subtitle>` elements name language, purpose and forced/SDH -//! state directly, with no bytecode to decode. NOT A SPECIFICATION — this is -//! one authoring house's internal metadata; every field meaning was read off -//! a real disc. See docs/fox.md for the full field mapping, confirmed -//! schema, and feature-playlist scoping rationale. +//! Root `<dcx><disc>` lists playlists; the feature playlist's nested `<audio>`/`<subtitle>` +//! elements name language, purpose and forced/SDH state directly, with no bytecode to decode. +//! NOT A SPECIFICATION — this is one authoring house's internal metadata; every field meaning +//! was read off a real disc. //! //! ```xml //! <dcx> @@ -85,9 +83,8 @@ pub(crate) fn feature_hint(text: &str) -> Option<super::FeaturePlaylistHint> { }) } -// Build stream labels from a `dcx.xml` doc; split out from `parse` for unit -// testing. Scoped to the richest `<playlist name="feature">` element — see -// docs/fox.md for why merging playlists is wrong. +// Build stream labels from a `dcx.xml` doc; split out from `parse` for unit testing. Scoped to +// the richest `<playlist name="feature">` element. pub(crate) fn labels_from_dcx(text: &str) -> Vec<StreamLabel> { let Some(feature) = select_feature_playlist(text) else { return Vec::new(); @@ -167,9 +164,9 @@ fn playlist_duration_secs(element: &str) -> Option<u64> { digits.parse::<u64>().ok().filter(|&d| d > 0) } -// Pick the richest `<playlist name="feature">` (most nested streams; a `durs` -// tiebreak breaks a count tie by longest time; first feature wins a full tie; a -// stated sub-minute feature is skipped). See docs/fox.md for full rationale. +// Pick the richest `<playlist name="feature">` (most nested streams; a `durs` tiebreak breaks a +// count tie by longest time; first feature wins a full tie; a stated sub-minute feature is +// skipped). fn select_feature_playlist(text: &str) -> Option<&str> { // rank = (nested stream count, durs seconds). None durs sorts as 0 so it // never displaces a stream-count-equal sibling that states a longer time. diff --git a/src/labels/jar.rs b/src/labels/jar.rs index 8959f2dd..5c46f267 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -13,14 +13,13 @@ use crate::udf::UdfFs; use std::io::{Cursor, Read}; use zip::ZipArchive; -// Cap on bytes read from one `.class` entry — the jar's declared size -// is attacker-controlled, so the buffer grows incrementally instead of -// pre-sizing. See docs/jar.md — MAX_CLASS_BYTES rationale. +// Cap on bytes read from one `.class` entry — the jar's declared size is attacker-controlled, +// so the buffer grows incrementally instead of pre-sizing. const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024; -// Cap on bytes read from one non-`.class` resource entry (the dcx.xml / -// playlists.xml / *.properties manifests the Tier-1 sweep reads). Same -// attacker-controlled-size defence as MAX_CLASS_BYTES — see docs/jar.md. +// Cap on bytes read from one non-`.class` resource entry (the dcx.xml / playlists.xml / +// *.properties manifests the Tier-1 sweep reads). Same attacker-controlled-size defence as +// MAX_CLASS_BYTES. const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024; /// In-memory zip archive: backed by a `Vec<u8>` read from UDF. Owns @@ -41,7 +40,6 @@ pub fn for_each_jar<R, F>(reader: &mut dyn SectorSource, udf: &UdfFs, mut f: F) where F: FnMut(&str, &mut Jar) -> Option<R>, { - // See docs/jar.md — for_each_jar "top-level" vendor examples. let jar_dir = udf.find_dir("/BDMV/JAR")?; for entry in &jar_dir.entries { if entry.is_dir { @@ -315,9 +313,6 @@ mod tests { assert!(parsed); } - // See docs/jar.md — read_is_bounded_by_cap rationale. - // Read is bounded by MAX_CLASS_BYTES; the entry still parses and - // is surfaced exactly once despite a forged huge declared size. #[test] fn read_is_bounded_by_cap() { // Padding past MINIMAL_CLASS is harmless trailing data the parser diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 63a33e68..6d39a409 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -312,9 +312,8 @@ fn languages_agree(a: &str, b: &str) -> bool { !a.is_empty() && a.eq_ignore_ascii_case(b) } -// The VENDOR label occupying 1-based STN slot `n` of `stream_type`, if -// any (derived labels bind by StreamId instead, not by slot). -// See docs/labels-mod.md — label_at. +// The VENDOR label occupying 1-based STN slot `n` of `stream_type`, if any (derived labels bind +// by StreamId instead, not by slot). fn label_at(labels: &[StreamLabel], stream_type: StreamLabelType, n: u16) -> Option<&StreamLabel> { labels .iter() @@ -347,9 +346,8 @@ fn slots_of(title: &DiscTitle, stream_type: StreamLabelType) -> Vec<(u16, &str)> .collect() } -// Find the title the label list is actually describing, for one stream -// type: the title whose per-slot language sequence best matches (fewest -// contradictions, most confirmations). See docs/labels-mod.md — find_anchor. +// Find the title the label list is actually describing, for one stream type: the title whose +// per-slot language sequence best matches (fewest contradictions, most confirmations). fn find_anchor( labels: &[StreamLabel], titles: &[DiscTitle], @@ -374,9 +372,8 @@ fn find_anchor( best.map(|(_, _, idx)| idx) } -// How strongly this title matches the vendor label list (count of -// confirmed slots), or None if it contradicts and can't be the table. -// See docs/labels-mod.md — anchor_score. +// How strongly this title matches the vendor label list (count of confirmed slots), or None if +// it contradicts and can't be the table. fn anchor_score( labels: &[StreamLabel], title: &DiscTitle, @@ -397,9 +394,8 @@ fn anchor_score( Some(confirmed) } -// Apply a pre-extracted set of labels to titles' streams: 4 tiers, most -// certain first — anchor title, anchor-proved PID, StreamId, then -// language-checked ordinal fallback. See docs/labels-mod.md — apply_labels. +// Apply a pre-extracted set of labels to titles' streams: 4 tiers, most certain first — anchor +// title, anchor-proved PID, StreamId, then language-checked ordinal fallback. pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { use std::collections::HashMap; @@ -671,9 +667,8 @@ fn generate_video_label( parts.join(" ") } -// Does codec_hint name a codec consistent with the stream's actual codec -// (matched by family, e.g. Atmos on TrueHD)? Rejects shuffled-label -// mis-binds. See docs/labels-mod.md — codec_hint_consistent. +// Does codec_hint name a codec consistent with the stream's actual codec (matched by family, +// e.g. Atmos on TrueHD)? Rejects shuffled-label mis-binds. fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool { use crate::disc::Codec; let h = hint.to_ascii_lowercase(); @@ -741,9 +736,8 @@ pub(crate) fn generate_audio_label( generate_audio_label_inner(codec, channels, secondary, false) } -// Atmos-aware variant of generate_audio_label: folds the object-audio -// marker into the codec brand (e.g. "Dolby TrueHD Atmos 7.1"). -// See docs/labels-mod.md — generate_audio_label_atmos. +// Atmos-aware variant of generate_audio_label: folds the object-audio marker into the codec +// brand (e.g. "Dolby TrueHD Atmos 7.1"). pub(crate) fn generate_audio_label_atmos( codec: &crate::disc::Codec, channels: &crate::disc::AudioChannels, @@ -864,9 +858,8 @@ fn extract( (labels, feature_playlist) } -// Merge the MPLS-derived floor into a framework list. Nothing is merged -// BY slot (the lists don't share a coordinate system); unnamed MPLS -// streams are appended by StreamId. See docs/labels-mod.md — merge_mpls_floor. +// Merge the MPLS-derived floor into a framework list. Nothing is merged BY slot (the lists +// don't share a coordinate system); unnamed MPLS streams are appended by StreamId. fn merge_mpls_floor(framework: &mut Vec<StreamLabel>, mpls: &[StreamLabel]) { use std::collections::HashSet; let named: HashSet<&StreamId> = framework @@ -918,9 +911,8 @@ fn type_tag(t: StreamLabelType) -> u8 { } } -// Append CLPI streams (present but unreferenced by MPLS) not already -// named; each binds by its own (clip, PID), no invented ordinal. -// Returns count appended. See docs/labels-mod.md — append_clpi_orphans. +// Append CLPI streams (present but unreferenced by MPLS) not already named; each binds by its +// own (clip, PID), no invented ordinal. Returns count appended. fn append_clpi_orphans( labels: &mut Vec<StreamLabel>, reader: &mut dyn SectorSource, @@ -1029,9 +1021,8 @@ fn append_clpi_orphans( added } -// Pick the winning parser result: highest Confidence among non-empty -// results, earliest array position wins ties (matches extract()'s scan). -// See docs/labels-mod.md — select_result. +// Pick the winning parser result: highest Confidence among non-empty results, earliest array +// position wins ties (matches extract()'s scan). fn select_result<'a>( results: &'a [(&'static str, ParseResult)], ) -> Option<&'a (&'static str, ParseResult)> { @@ -1124,9 +1115,8 @@ pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis { } } -// Scan /BDMV/PLAYLIST/*.mpls for a chapter-count + duration row per -// playlist, sorted by filename. Unparseable/markless entries silently -// dropped. See docs/labels-mod.md — collect_chapter_summary. +// Scan /BDMV/PLAYLIST/*.mpls for a chapter-count + duration row per playlist, sorted by +// filename. Unparseable/markless entries silently dropped. 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(); @@ -1227,9 +1217,8 @@ pub struct ChapterSummary { pub duration_secs: f64, } -// List filenames under any /BDMV/JAR/<x>/ subdirectory, deduped and -// sorted (empty if none). pub(crate) so filename parsers can scan -// menu-asset names without a reader. See docs/labels-mod.md — jar_inventory. +// List filenames under any /BDMV/JAR/<x>/ subdirectory, deduped and sorted (empty if none). +// pub(crate) so filename parsers can scan menu-asset names without a reader. pub(crate) fn jar_inventory(udf: &UdfFs) -> Vec<String> { let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else { return Vec::new(); @@ -1237,9 +1226,8 @@ pub(crate) fn jar_inventory(udf: &UdfFs) -> Vec<String> { jar_inventory_from(&jar_dir.entries) } -// Body of jar_inventory, unit-testable without a UdfFs. Uses a BTreeSet -// (not Vec::contains) since entry names are attacker-controlled disc -// data. See docs/labels-mod.md — jar_inventory_from. +// Body of jar_inventory, unit-testable without a UdfFs. Uses a BTreeSet (not Vec::contains) +// since entry names are attacker-controlled disc data. fn jar_inventory_from(entries: &[crate::udf::DirEntry]) -> Vec<String> { let mut out: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); for entry in entries { @@ -1308,9 +1296,8 @@ mod registry_tests { } } - // Hang guard: a return to a linear Vec::contains dedup in - // jar_inventory would make this 120k-entry fixture run for minutes. - // See docs/labels-mod.md — jar_inventory_dedup_does_not_hang_on_a_hostile_directory. + // Hang guard: a return to a linear Vec::contains dedup in jar_inventory would make this + // 120k-entry fixture run for minutes. #[test] fn jar_inventory_dedup_does_not_hang_on_a_hostile_directory() { const FILES: usize = 120_000; @@ -1377,9 +1364,8 @@ mod registry_tests { ); } - // Lock the parser roster + order: first matching parse() wins, so - // reordering changes which parser claims a disc. dbp + deluxe MUST - // stay last. See docs/labels-mod.md — parsers_registry_order_locked. + // Lock the parser roster + order: first matching parse() wins, so reordering changes which + // parser claims a disc. dbp + deluxe MUST stay last. #[test] fn parsers_registry_order_locked() { let names: Vec<&str> = PARSERS.iter().map(|(n, _, _)| *n).collect(); @@ -1454,9 +1440,8 @@ mod registry_tests { assert!(!h.is_empty()); } - // select_result must pick highest confidence, first-in-array on a - // tie (regression: old analyze() no-op picked the LAST). - // See docs/labels-mod.md — select_result_first_wins_on_tie. + // select_result must pick highest confidence, first-in-array on a tie (regression: old + // analyze() no-op picked the LAST). #[test] fn select_result_first_wins_on_tie() { // Two parsers, equal (Medium) confidence: the first must win. @@ -1561,9 +1546,8 @@ mod gap_fill_tests { assert_eq!(framework.len(), 3); } - // Spec: merge suppresses a floor entry only when the list already - // NAMES that stream — a shared slot number is not the same fact. - // See docs/labels-mod.md — suppression_is_by_named_stream_not_by_slot_number. + // Spec: merge suppresses a floor entry only when the list already NAMES that stream — a + // shared slot number is not the same fact. #[test] fn suppression_is_by_named_stream_not_by_slot_number() { // Framework claims audio slots 1 and 2 but names no stream. @@ -1797,9 +1781,8 @@ mod apply_tests { /// fooled by another test's log output. const LOG_INJECTION_SENTINEL: &str = "FMKV-LOG-INJECTION-PROBE"; - // A crafted .mpls filename must be logged via `?` (Debug), not `%` - // (Display) — Display writes control/ANSI bytes verbatim (CWE-117). - // See docs/labels-mod.md — log-injection guard on playlist-name logging. + // A crafted.mpls filename must be logged via `?` (Debug), not `%` (Display) — Display + // writes control/ANSI bytes verbatim (CWE-117). #[test] fn a_disc_derived_playlist_name_is_escaped_in_the_log_not_written_verbatim() { // A name whose bytes would clear the line and repaint it. @@ -2241,9 +2224,8 @@ mod apply_tests { } } - // Cross-playlist mis-binding this module's two-tier binding stops: - // ordinal numbering would put Forced on different PIDs per sibling - // playlist. See docs/labels-mod.md — forced_label_follows_the_pid_not_the_ordinal_across_sibling_playlists. + // Cross-playlist mis-binding this module's two-tier binding stops: ordinal numbering would + // put Forced on different PIDs per sibling playlist. #[test] fn forced_label_follows_the_pid_not_the_ordinal_across_sibling_playlists() { // Six slots: three plain, a commentary subtitle, then two forced. @@ -2314,9 +2296,8 @@ mod apply_tests { ); } - // No anchor: ordinal fallback drops a label whose language - // contradicts the stream — unlabelled beats mislabelled `forced`. - // See docs/labels-mod.md — ordinal_binding_drops_a_subtitle_label_that_contradicts_the_stream_language. + // No anchor: ordinal fallback drops a label whose language contradicts the stream — + // unlabelled beats mislabelled `forced`. #[test] fn ordinal_binding_drops_a_subtitle_label_that_contradicts_the_stream_language() { let labels = vec![ @@ -2662,9 +2643,8 @@ mod apply_tests { assert!(!codec_hint_adds_detail("")); } - // ── generate_video_label hardening: secondary Dolby Vision stream - // gets "Dolby Vision EL", every other HDR format on a secondary - // stream gets no label. See docs/labels-mod.md — generate_video_label_secondary_dolby_vision_el. + // ── generate_video_label hardening: secondary Dolby Vision stream gets "Dolby Vision EL", + // every other HDR format on a secondary stream gets no label. #[test] fn generate_video_label_secondary_dolby_vision_el() { assert_eq!( @@ -2684,9 +2664,8 @@ mod apply_tests { ); } - // Spec: 480 lines is the SD floor — height exactly 480 must get the - // "480p"/"480i" token, not the empty-resolution case. - // See docs/labels-mod.md — generate_video_label_480_boundary. + // Spec: 480 lines is the SD floor — height exactly 480 must get the "480p"/"480i" token, + // not the empty-resolution case. #[test] fn generate_video_label_480_boundary() { let label = generate_video_label(&Codec::Mpeg2, (0, 480), false, &HdrFormat::Sdr, false); @@ -2696,9 +2675,8 @@ mod apply_tests { ); } - // Spec: SDR is the unmarked default — must never appear as a token - // (only non-SDR formats get an explicit tag). - // See docs/labels-mod.md — generate_video_label_sdr_produces_no_hdr_token. + // Spec: SDR is the unmarked default — must never appear as a token (only non-SDR formats + // get an explicit tag). #[test] fn generate_video_label_sdr_produces_no_hdr_token() { assert_eq!( @@ -2709,8 +2687,7 @@ mod apply_tests { // ── generate_audio_label_atmos ─────────────────────────────────────── - // Spec: the Atmos-aware variant folds "Atmos" into the codec brand - // for TrueHD/DD+ carriers. See docs/labels-mod.md — generate_audio_label_atmos_folds_brand. + // Spec: the Atmos-aware variant folds "Atmos" into the codec brand for TrueHD/DD+ carriers. #[test] fn generate_audio_label_atmos_folds_brand() { assert_eq!( @@ -2723,8 +2700,8 @@ mod apply_tests { ); } - // Spec: every disc-audio codec has a full marketing name, including - // lossy PC-container codecs. See docs/labels-mod.md — generate_audio_label_covers_pc_container_codecs. + // Spec: every disc-audio codec has a full marketing name, including lossy PC-container + // codecs. #[test] fn generate_audio_label_covers_pc_container_codecs() { assert_eq!( @@ -2775,7 +2752,6 @@ mod apply_tests { } // Isolates the "pcm" (no "lpcm") synonym in says_lpcm. - // See docs/labels-mod.md — codec_hint_consistent_lpcm_bare_pcm_synonym. #[test] fn codec_hint_consistent_lpcm_bare_pcm_synonym() { assert!(codec_hint_consistent("PCM", &Codec::Lpcm)); @@ -2783,7 +2759,6 @@ mod apply_tests { } // Isolates says_dts_ma || says_dts_hr inside names_family. - // See docs/labels-mod.md — codec_hint_consistent_names_family_dts_ma_alone. #[test] fn codec_hint_consistent_names_family_dts_ma_alone() { assert!(!codec_hint_consistent("Master Audio", &Codec::Ac3)); @@ -2791,7 +2766,6 @@ mod apply_tests { } // Isolates the Codec::TrueHd => says_truehd || says_atmos arm. - // See docs/labels-mod.md — codec_hint_consistent_truehd_arm_atmos_alone. #[test] fn codec_hint_consistent_truehd_arm_atmos_alone() { assert!(codec_hint_consistent( @@ -2800,8 +2774,8 @@ mod apply_tests { )); } - // Spec: Codec::Dts is consistent ONLY when says_dts is true, not - // via any other named family. See docs/labels-mod.md — codec_hint_consistent_dts_arm_not_bypassed. + // Spec: Codec::Dts is consistent ONLY when says_dts is true, not via any other named + // family. #[test] fn codec_hint_consistent_dts_arm_not_bypassed() { assert!(!codec_hint_consistent("Dolby Digital", &Codec::Dts)); @@ -2838,9 +2812,8 @@ mod apply_tests { } } - // Spec: a label that names its own stream is never reachable - // through the slot lookup (different coordinate systems). - // See docs/labels-mod.md — slot_lookup_never_returns_a_label_that_names_its_own_stream. + // Spec: a label that names its own stream is never reachable through the slot lookup + // (different coordinate systems). #[test] fn slot_lookup_never_returns_a_label_that_names_its_own_stream() { let labels = vec![ @@ -2852,9 +2825,8 @@ mod apply_tests { assert_eq!(found.qualifier, LabelQualifier::Sdh); } - // Spec: the derived floor does not vote on which title anchors the - // vendor list, and cannot VETO the title that does. - // See docs/labels-mod.md — the_derived_floor_cannot_veto_the_anchor. + // Spec: the derived floor does not vote on which title anchors the vendor list, and cannot + // VETO the title that does. #[test] fn the_derived_floor_cannot_veto_the_anchor() { let labels = vec![ @@ -2878,9 +2850,8 @@ mod apply_tests { ); } - // Spec: among titles the vendor list admits, the one that CONFIRMS - // more of it wins — silence is not evidence, size alone must not win. - // See docs/labels-mod.md — the_anchor_is_the_title_that_confirms_most_of_the_list. + // Spec: among titles the vendor list admits, the one that CONFIRMS more of it wins — + // silence is not evidence, size alone must not win. #[test] fn the_anchor_is_the_title_that_confirms_most_of_the_list() { let labels = vec![ @@ -2911,9 +2882,8 @@ mod apply_tests { ); } - // Spec: a vendor qualifier does not leak via ordinal fallback onto a - // different physical stream (same ordinal, different clip). - // See docs/labels-mod.md — a_vendor_qualifier_does_not_leak_onto_a_featurettes_own_stream. + // Spec: a vendor qualifier does not leak via ordinal fallback onto a different physical + // stream (same ordinal, different clip). #[test] fn a_vendor_qualifier_does_not_leak_onto_a_featurettes_own_stream() { let labels = vec![ @@ -2966,9 +2936,8 @@ mod apply_tests { } } - // Spec: the anchor proves a (clip, PID) fact only for the clip its - // stream table was READ FROM (the first play item), never every clip - // it plays. See docs/labels-mod.md — an_anchor_proves_pids_only_for_the_clip_its_table_came_from. + // Spec: the anchor proves a (clip, PID) fact only for the clip its stream table was READ + // FROM (the first play item), never every clip it plays. #[test] fn an_anchor_proves_pids_only_for_the_clip_its_table_came_from() { let labels = vec![ @@ -3006,9 +2975,8 @@ mod apply_tests { ); } - // Spec: a vendor codec/variant claim does not follow the ordinal - // onto a bonus clip that carries a different codec. - // See docs/labels-mod.md — a_vendor_codec_claim_does_not_follow_the_ordinal_onto_a_bonus_clip. + // Spec: a vendor codec/variant claim does not follow the ordinal onto a bonus clip that + // carries a different codec. #[test] fn a_vendor_codec_claim_does_not_follow_the_ordinal_onto_a_bonus_clip() { // This framework states no codec_hint and puts its descriptor in `name`, @@ -3055,9 +3023,8 @@ mod apply_tests { ); } - // Spec: a title whose stream count is smaller than the vendor - // list's highest slot cannot be the table that list describes. - // See docs/labels-mod.md — a_title_shorter_than_the_vendor_list_cannot_anchor_it. + // Spec: a title whose stream count is smaller than the vendor list's highest slot cannot be + // the table that list describes. #[test] fn a_title_shorter_than_the_vendor_list_cannot_anchor_it() { let labels = vec![ @@ -3092,8 +3059,8 @@ mod apply_tests { ); } - // Spec: a slot the vendor list never names constrains nothing (these - // blobs under-yield by design). See docs/labels-mod.md — an_unnamed_slot_does_not_disqualify_a_title. + // Spec: a slot the vendor list never names constrains nothing (these blobs under-yield by + // design). #[test] fn an_unnamed_slot_does_not_disqualify_a_title() { // The vendor names slots 1 and 3 only; slot 2 is its silence. @@ -3146,9 +3113,8 @@ mod fill_gaps_sort_tests { } } - // Spec: the sort-by-(type, number) pass only runs when the merge - // actually added something; a no-op merge must leave order untouched. - // See docs/labels-mod.md — fill_gaps_leaves_order_untouched_when_nothing_added. + // Spec: the sort-by-(type, number) pass only runs when the merge actually added something; + // a no-op merge must leave order untouched. #[test] fn fill_gaps_leaves_order_untouched_when_nothing_added() { // Deliberately out of (type, number) order: number 2 before 1. @@ -3191,9 +3157,8 @@ mod clpi_orphan_tests { } } - // Build a CLPI ProgramInfo section for one program from - // (pid, stream_coding_info) pairs, per crate::clpi::parse_program_info. - // See docs/labels-mod.md — build_program_info / build_clpi. + // Build a CLPI ProgramInfo section for one program from (pid, stream_coding_info) pairs, + // per crate::clpi::parse_program_info. fn build_program_info(streams: &[(u16, Vec<u8>)]) -> Vec<u8> { let mut body = Vec::new(); body.push(0); // reserved @@ -3213,8 +3178,8 @@ mod clpi_orphan_tests { out } - // Build a full CLPI buffer (HDMV header + ProgramInfo) for - // (pid, coding_type, lang) streams. See docs/labels-mod.md — build_program_info / build_clpi. + // Build a full CLPI buffer (HDMV header + ProgramInfo) for (pid, coding_type, lang) + // streams. fn build_clpi(streams: &[(u16, u8, &str)]) -> Vec<u8> { use crate::consts::coding_type as c; let sci_streams: Vec<(u16, Vec<u8>)> = streams @@ -3350,9 +3315,8 @@ mod clpi_orphan_tests { assert!(labels.is_empty()); } - // (d) An orphan states NO STN slot, and is identified by the stream - // it was read from instead (an invented ordinal could reach it). - // See docs/labels-mod.md — orphans_state_no_stn_slot_and_name_their_stream. + // (d) An orphan states NO STN slot, and is identified by the stream it was read from + // instead (an invented ordinal could reach it). #[test] fn orphans_state_no_stn_slot_and_name_their_stream() { let mut disc = MemDisc::new(); diff --git a/src/labels/mpls_universal.rs b/src/labels/mpls_universal.rs index 413570a2..20f25eb7 100644 --- a/src/labels/mpls_universal.rs +++ b/src/labels/mpls_universal.rs @@ -6,8 +6,6 @@ //! (dbp, pixelogic, ctrm, criterion, ...) extract richer editorial //! labels when the disc matches a recognized authoring tool; this //! module is the fallback, always Low confidence. -//! -//! See docs/mpls-universal.md for the full rationale. use super::{ LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, @@ -74,7 +72,6 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> } // Converts stream entries into StreamLabels; identity is (clip, PID). -// See docs/mpls-universal.md — build_labels rationale. fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec<StreamLabel> { use std::collections::HashSet; let mut labels: Vec<StreamLabel> = Vec::new(); @@ -137,8 +134,8 @@ fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec<StreamLabel> { labels } -// Per-type numbering list an STN entry belongs to, or None if unlabellable. -// MUST agree with disc::bluray's stream list (see docs/mpls-universal.md). +// Per-type numbering list an STN entry belongs to, or None if unlabellable. MUST agree with +// disc::bluray's stream list. fn label_type_for(entry: &crate::mpls::StreamEntry) -> Option<StreamLabelType> { use crate::consts::coding_type as c; if entry.coding_type == 0 { @@ -330,9 +327,8 @@ mod tests { } } - // A playlist over clip "00001" with one play item, so each label gets - // the `(clip, PID)` identity it is bound by. - // See docs/mpls-universal.md — test helpers and fixtures. + // A playlist over clip "00001" with one play item, so each label gets the `(clip, PID)` + // identity it is bound by. fn playlist_with(streams: Vec<StreamEntry>) -> Playlist { playlist_on("00001", streams) } @@ -351,9 +347,8 @@ mod tests { } } - // Drives the real build_labels (what parse() calls) from parsed - // Playlists so mutations there are actually caught here. - // See docs/mpls-universal.md — test helpers and fixtures. + // Drives the real build_labels (what parse() calls) from parsed Playlists so mutations + // there are actually caught here. fn labels_from_playlists(playlists: &[Playlist]) -> Vec<StreamLabel> { build_labels(playlists) } @@ -411,9 +406,8 @@ mod tests { assert_eq!(labels[2].language, "fra"); } - // disc::bluray drops coding_type==0 (STN padding) from the stream - // list that stream_number binds against, so it must not be counted here. - // See docs/mpls-universal.md — padding_stn_entry_does_not_consume_a_label_slot. + // disc::bluray drops coding_type==0 (STN padding) from the stream list that stream_number + // binds against, so it must not be counted here. #[test] fn padding_stn_entry_does_not_consume_a_label_slot() { let pl = playlist_with(vec![ @@ -434,9 +428,8 @@ mod tests { ); } - // A PG coding_type in an audio STN slot is a real, documented shape; - // disc::bluray classifies it Subtitle, so this module must match. - // See docs/mpls-universal.md — pg_coding_type_in_an_audio_slot_counts_as_a_subtitle. + // A PG coding_type in an audio STN slot is a real, documented shape; disc::bluray + // classifies it Subtitle, so this module must match. #[test] fn pg_coding_type_in_an_audio_slot_counts_as_a_subtitle() { let mut misplaced = audio_entry(0x1200, 0x90, 0, 0, "spa"); @@ -523,9 +516,8 @@ mod tests { assert_eq!(num("deu"), Some(2), "pl2's second audio slot"); } - // Same PID in two DIFFERENT clips is two streams — a PID is only - // unique within one clip, not deduped across clips. - // See docs/mpls-universal.md — same_pid_in_two_clips_is_two_streams. + // Same PID in two DIFFERENT clips is two streams — a PID is only unique within one clip, + // not deduped across clips. #[test] fn same_pid_in_two_clips_is_two_streams() { let pl1 = playlist_on("00001", vec![audio_entry(0x1100, 0x83, 12, 1, "eng")]); diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index 38bdf4ef..92b8ab79 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -2,10 +2,8 @@ //! language lists with forced flags and commentary indices per playlist, //! all in XML attributes. //! -//! NOT A SPECIFICATION: `/BDMV/JAR/` is application-defined space; every -//! field meaning here was derived by measuring real discs — see -//! docs/paramount.md for the derivation. The disc's own `forced_on_flag` -//! is the only authoritative forced signal. +//! NOT A SPECIFICATION: `/BDMV/JAR/` is application-defined space; every field meaning here was +//! derived by measuring real discs. //! //! ```xml //! <playlist name="Feature" id="00222" @@ -65,9 +63,7 @@ fn feature_hint(feature: &str) -> Option<super::FeaturePlaylistHint> { }) } -// One cell of the `forced_sub` CSV. Reads like a boolean but is an -// enumeration; see docs/paramount.md for the corpus measurements behind -// each value. Unrecognised cells map to `None` — the conservative direction. +// One cell of the `forced_sub` CSV. Reads like a boolean but is an enumeration. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum ForcedSub { /// No forced-narrative content, or an unrecognised cell. @@ -78,14 +74,12 @@ enum ForcedSub { ForcedNarrative, } -// The highest CSV cell position that can ever be addressed — caps both the -// VALUE set size and the WORK done per cell. See docs/paramount.md for why -// those are two different bounds. Real authoring never approaches it. +// The highest CSV cell position that can ever be addressed — caps both the VALUE set size and +// the WORK done per cell. const MAX_COM_INDICES: usize = u16::MAX as usize; -// Parse a `*_com1_idx` attribute into the set the labelling loops query. -// Extracted so the bound is independently testable — see docs/paramount.md -// (`com_indices` / `forced_subs`) for why a label-level test cannot see it. +// Parse a `*_com1_idx` attribute into the set the labelling loops query. Extracted so the bound +// is independently testable. fn com_indices(attr: Option<String>) -> HashSet<usize> { attr.map(|s| { s.split(',') @@ -97,9 +91,8 @@ fn com_indices(attr: Option<String>) -> HashSet<usize> { .unwrap_or_default() } -// Parse `forced_sub` into the cell list the subtitle loop queries. Bounded -// by POSITION rather than value (nothing to filter on a classification) — -// see docs/paramount.md (`com_indices` / `forced_subs`) for why. +// Parse `forced_sub` into the cell list the subtitle loop queries. Bounded by POSITION rather +// than value (nothing to filter on a classification). fn forced_subs(attr: Option<String>) -> Vec<ForcedSub> { attr.map(|s| { s.split(',') @@ -349,9 +342,7 @@ fn pick(cands: &[Candidate], key: Key, require_audio: bool) -> Option<String> { mod tests { use super::*; - // Immunity pin, section-boundary half — see docs/paramount.md. - // Mutation: hand `labels_from_feature` the document instead of the - // element, or let an unterminated element run to EOF. + // Immunity pin, section-boundary half. #[test] fn a_playlists_stream_list_cannot_run_into_the_next_playlist() { let doc = r#" @@ -386,9 +377,7 @@ mod tests { ); } - // Replaces a flaky wall-clock test; see docs/paramount.md for why. - // Guards that bounding the parse did not change what a legitimate - // playlist MEANS (real indices `0,2,4` must still resolve). + // Replaces a flaky wall-clock test. #[test] fn bounding_the_parse_does_not_change_a_legitimate_playlist() { // Three real indices, then far more entries than can address a cell. @@ -413,9 +402,8 @@ mod tests { assert_eq!(labels[4].purpose, LabelPurpose::Commentary); } - // The set REFUSES unaddressable indices. DISTINCT values on purpose — a - // `HashSet` collapses repeats, so only distinct entries can prove the - // filter exists; see docs/paramount.md for the full rationale. + // The set REFUSES unaddressable indices. DISTINCT values on purpose — a `HashSet` collapses + // repeats, so only distinct entries can prove the filter exists. #[test] fn distinct_unaddressable_indices_are_refused_not_stored() { let hostile: String = (MAX_COM_INDICES..MAX_COM_INDICES + 50_000) @@ -432,9 +420,8 @@ mod tests { assert_eq!(com_indices(Some("0,2,4".to_string())).len(), 3); } - // `forced_sub` is bounded too, and read through `forced_subs` rather - // than the labels for the same reason as the tests above — see - // docs/paramount.md. + // `forced_sub` is bounded too, and read through `forced_subs` rather than the labels for + // the same reason as the tests above. #[test] fn forced_sub_cells_past_the_last_addressable_one_are_not_parsed() { let hostile = "0,".repeat(MAX_COM_INDICES + 50_000); @@ -463,9 +450,8 @@ mod tests { ); } - // An index that cannot address any cell is dropped rather than STORED. - // Asserted through `com_indices`, not the labels — see docs/paramount.md - // for why a label-level assertion here could not fail. + // An index that cannot address any cell is dropped rather than STORED. Asserted through + // `com_indices`, not the labels. #[test] fn an_index_that_cannot_address_any_cell_is_not_retained() { let set = com_indices(Some(format!( @@ -515,9 +501,8 @@ mod tests { .collect() } - // An empty CSV cell carries nothing to label but still OCCUPIES its STN - // slot, so it must not renumber the slots behind it — see - // docs/paramount.md for how the old renumbering misplaced a forced flag. + // An empty CSV cell carries nothing to label but still OCCUPIES its STN slot, so it must + // not renumber the slots behind it. #[test] fn empty_csv_slot_still_occupies_its_stn_slot() { // Audio: slot 2 is empty; `fra` is STN slot 3 and is the commentary @@ -760,9 +745,8 @@ mod tests { assert_eq!(s[2].qualifier, LabelQualifier::None); } - // `forced_sub` is an enumeration; `1` means "full dialogue track that - // also carries forced signs", NOT "this track is forced" — see - // docs/paramount.md. Mutation: `"1" => ForcedNarrative` (old reading). + // `forced_sub` is an enumeration; `1` means "full dialogue track that also carries forced + // signs", NOT "this track is forced". #[test] fn a_contains_forced_segments_cell_is_not_a_forced_track() { let feature = r#"<playlist name="Feature" sub="eng,ces,deu" forced_sub="0,1,1" />"#; @@ -775,9 +759,8 @@ mod tests { ); } - // `2` and `3` are the cells that DO name a dedicated forced-narrative - // track, and the old boolean reading discarded both — see - // docs/paramount.md. Mutation: drop either arm of the `"2" | "3"` match. + // `2` and `3` are the cells that DO name a dedicated forced-narrative track, and the old + // boolean reading discarded both. #[test] fn a_dedicated_forced_narrative_cell_is_a_forced_track() { // The measured shape: full tracks first, their forced companions in @@ -795,9 +778,8 @@ mod tests { assert_eq!(s[4].stream_number, 5); } - // An unrecognised cell must fall to NOT forced — asserting forced is - // the expensive mistake (see docs/paramount.md). Mutation: - // `_ => ForcedNarrative`, or treating "any non-zero" as forced. + // An unrecognised cell must fall to NOT forced — asserting forced is the expensive mistake. + // Mutation: `_ => ForcedNarrative`, or treating "any non-zero" as forced. #[test] fn an_unrecognised_forced_sub_cell_is_not_forced() { let feature = r#"<playlist name="Feature" sub="eng,fra,spa,ita" forced_sub="4,x,,-1" />"#; @@ -835,9 +817,8 @@ mod tests { assert!(feature_hint(r#"<playlist id="menu" />"#).is_none()); } - // Spec: on a tie in audio-slot count, the FIRST playlist wins (see - // docs/paramount.md). Mutation: `count > best_aud_count` -> - // `count >= best_aud_count` lets a later tie silently displace it. + // Spec: on a tie in audio-slot count, the FIRST playlist wins. Mutation: `count > + // best_aud_count` -> `count >= best_aud_count` lets a later tie silently displace it. #[test] fn find_feature_first_wins_on_audio_count_tie() { let xml = r#" diff --git a/src/labels/pixelogic.rs b/src/labels/pixelogic.rs index 23cfa01b..1313ab1c 100644 --- a/src/labels/pixelogic.rs +++ b/src/labels/pixelogic.rs @@ -14,30 +14,26 @@ use crate::udf::UdfFs; /// Known audio codec tokens const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"]; -// Ceiling on streams of one type per feature section: stops a crafted blob -// with tens of thousands of tokens from overflowing the u16 STN counters. -// See docs/pixelogic.md — MAX_STREAMS_PER_TYPE. +// Ceiling on streams of one type per feature section: stops a crafted blob with tens of +// thousands of tokens from overflowing the u16 STN counters. const MAX_STREAMS_PER_TYPE: u16 = 512; -// Ceiling on distinct video-slot entries one section may list before the -// walk gives up on it (used to find the next section's start). -// See docs/pixelogic.md — MAX_VIDEO_SLOTS. +// Ceiling on distinct video-slot entries one section may list before the walk gives up on it +// (used to find the next section's start). const MAX_VIDEO_SLOTS: usize = 33; /// Known region tokens const REGIONS: &[&str] = &[ "US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE", ]; -// Cap on distinct uncatalogued token components retained for the -// end-of-parse report; past the cap they're still counted, not named. -// See docs/pixelogic.md — MAX_REPORTED_UNKNOWN. +// Cap on distinct uncatalogued token components retained for the end-of-parse report; past the +// cap they're still counted, not named. const MAX_REPORTED_UNKNOWN: usize = 16; /// Longest retained form of a single uncatalogued component. Components come /// from disc bytes and can be arbitrarily long; truncation is by CHARS, not /// bytes, so a multi-byte sequence can never be split (which would panic). const MAX_UNKNOWN_LEN: usize = 32; -// Collects the uncatalogued token components one parse ran into, reported -// ONCE at the end rather than silently or per-occurrence. -// See docs/pixelogic.md — UnknownParts. +// Collects the uncatalogued token components one parse ran into, reported ONCE at the end +// rather than silently or per-occurrence. #[derive(Debug, Default)] struct UnknownParts { /// Distinct components, deduplicated and ordered for a stable log line. @@ -252,9 +248,8 @@ fn assign_labels(strings: &[String], unknown: &mut UnknownParts) -> Vec<StreamLa labels } -// The bare `Audio Stream N` / `PG Stream N` slot placeholders, and which -// list they belong to. `None` for anything else (`AR_…`, `Video Stream N`). -// See docs/pixelogic.md — placeholder_kind. +// The bare `Audio Stream N` / `PG Stream N` slot placeholders, and which list they belong to. +// `None` for anything else (`AR_…`, `Video Stream N`). fn placeholder_kind(s: &str) -> Option<StreamLabelType> { if s.starts_with("Audio Stream") { Some(StreamLabelType::Audio) @@ -265,9 +260,8 @@ fn placeholder_kind(s: &str) -> Option<StreamLabelType> { } } -// Whether a string has the shape of a pixelogic stream token — -// `{lang3}_{component}…` — the same gate `parse_token_inner` applies. -// See docs/pixelogic.md — is_stream_token. +// Whether a string has the shape of a pixelogic stream token — `{lang3}_{component}…` — the +// same gate `parse_token_inner` applies. fn is_stream_token(s: &str) -> bool { let clean = s.trim().trim_start_matches('\t').trim_end_matches('_'); let mut parts = clean.split('_'); @@ -473,9 +467,8 @@ mod tests { assert_eq!(l.qualifier, LabelQualifier::Forced); } - // Immunity pin against the `paramount`-parser defect where a "forced" - // marker on a FULL dialogue track's slot got read as "track is forced". - // See docs/pixelogic.md — a_full_subtitle_token_is_never_forced.... + // Immunity pin against the `paramount`-parser defect where a "forced" marker on a FULL + // dialogue track's slot got read as "track is forced". #[test] fn a_full_subtitle_token_is_never_forced_without_its_own_forced_component() { for token in [ @@ -567,9 +560,8 @@ mod tests { assert_eq!(audio[0].language, "eng"); } - // Real UHD feature shape: unlabelled/region-only PG slots must still - // occupy STN slots so trailing forced tokens number correctly. - // See docs/pixelogic.md — assign_labels_numbers_subtitles_by_stn_slot.... + // Real UHD feature shape: unlabelled/region-only PG slots must still occupy STN slots so + // trailing forced tokens number correctly. #[test] fn assign_labels_numbers_subtitles_by_stn_slot_not_by_parsed_token() { let mut flag = UnknownParts::default(); @@ -867,9 +859,8 @@ mod tests { assert!(audio.is_empty() || audio.iter().all(|l| l.stream_number <= 512)); } - // Isolates the `SF_` alternative in the end-of-section check from - // `SEG_`/`FPL_`, which a mutated `||` -> `&&` couldn't otherwise catch. - // See docs/pixelogic.md — assign_labels_fpl_section_ends_on_sf_boundary. + // Isolates the `SF_` alternative in the end-of-section check from `SEG_`/`FPL_`, which a + // mutated `||` -> `&&` couldn't otherwise catch. #[test] fn assign_labels_fpl_section_ends_on_sf_boundary() { let mut flag = UnknownParts::default(); @@ -884,9 +875,8 @@ mod tests { assert_eq!(labels[0].language, "eng"); } - // The feature section also ends where the NEXT section's own video slot - // repeats — the only boundary when the feature playlist is the last - // named section (real corpus disc; see docs/pixelogic.md for the shape). + // The feature section also ends where the NEXT section's own video slot repeats — the only + // boundary when the feature playlist is the last named section. #[test] fn assign_labels_section_ends_at_the_next_sections_video_slot() { let mut flag = UnknownParts::default(); @@ -975,9 +965,8 @@ mod tests { ); } - // The two per-type caps are independent: the loop only stops once BOTH - // reach MAX_STREAMS_PER_TYPE. Mutation: `&&` -> `||` stops on either. - // See docs/pixelogic.md — assign_labels_audio_cap_alone.... + // The two per-type caps are independent: the loop only stops once BOTH reach + // MAX_STREAMS_PER_TYPE. Mutation: `&&` -> `||` stops on either. #[test] fn assign_labels_audio_cap_alone_does_not_stop_subtitle_processing() { let mut flag = UnknownParts::default(); @@ -1000,9 +989,8 @@ mod tests { ); } - // Companion: subtitle saturated, audio under cap — audio must still - // process. Isolates the first `>=` operand from the second. - // See docs/pixelogic.md — assign_labels_subtitle_cap_alone.... + // Companion: subtitle saturated, audio under cap — audio must still process. Isolates the + // first `>=` operand from the second. #[test] fn assign_labels_subtitle_cap_alone_does_not_stop_audio_processing() { let mut flag = UnknownParts::default(); @@ -1174,9 +1162,8 @@ mod tests { assert!(stored.chars().all(|c| c == 'é')); } - // Per-language notice/disclaimer clip names merely COLLIDE with the - // token shape and must stay uncatalogued, collapsing into ONE report. - // See docs/pixelogic.md — unknown_parts_collapses_a_wall.... + // Per-language notice/disclaimer clip names merely COLLIDE with the token shape and must + // stay uncatalogued, collapsing into ONE report. #[test] fn unknown_parts_collapses_a_wall_of_segment_name_collisions() { let mut acc = UnknownParts::default(); diff --git a/src/labels/png_filenames.rs b/src/labels/png_filenames.rs index 520d99d9..3f8da435 100644 --- a/src/labels/png_filenames.rs +++ b/src/labels/png_filenames.rs @@ -6,8 +6,6 @@ //! `{LANG}` tokens found is the set of menu languages the disc ships. //! //! Language-only hint; runs at [`Confidence::Low`](super::Confidence::Low). -// See docs/png_filenames.md — why Low confidence, and why this is a -// first-class parser instead of inline logic. use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab}; use crate::sector::SectorSource; @@ -59,9 +57,8 @@ fn labels_from_filenames(names: &[String]) -> Vec<StreamLabel> { .collect() } -// Extract the ISO-639-2 lang code from a `{title}_UHD01_{LANG}_Composite` -// filename, or None if the grammar doesn't match or `{LANG}` isn't recognized. -// See docs/png_filenames.md — why the marker+suffix avoid false positives. +// Extract the ISO-639-2 lang code from a `{title}_UHD01_{LANG}_Composite` filename, or None if +// the grammar doesn't match or `{LANG}` isn't recognized. fn filename_lang(name: &str) -> Option<&'static str> { // Case-fold once; the marker/suffix are matched case-insensitively. let lower = name.to_ascii_lowercase(); diff --git a/src/labels/vocab.rs b/src/labels/vocab.rs index 8b781f8a..a5e30a03 100644 --- a/src/labels/vocab.rs +++ b/src/labels/vocab.rs @@ -5,9 +5,8 @@ //! multi-word language name → ISO 639-2, and English text → //! [`LabelPurpose`] / [`LabelQualifier`]. //! -//! Only maps values we are 100% certain about; unknown input passes through -//! raw or returns `None` — never guesses. Not for BD spec STN codec IDs; -//! those decode in `mpls.rs`. See docs/vocab.md for full rules of engagement. +//! Only maps values we are 100% certain about; unknown input passes through raw or returns +//! `None` — never guesses. Not for BD spec STN codec IDs; those decode in `mpls.rs`. use super::{LabelPurpose, LabelQualifier}; @@ -53,12 +52,10 @@ pub struct LangInfo { /// Map a free-form language label fragment to an ISO 639-2 code AND /// (where applicable) its regional variant. /// -/// Handles bare English names ("English") and multi-word vendor variants -/// ("Brazilian Portuguese"); compounds are checked before bare names, so -/// the latter returns `variant: "Brazilian"` rather than a bare -/// "Portuguese" match. Bare matches return `variant: ""`; `None` means -/// unrecognized input (never a guess). See docs/vocab.md for the -/// `COMPOUND_LANGS` ordering rule and the [`StreamLabel::variant`](super::StreamLabel) rationale. +/// Handles bare English names ("English") and multi-word vendor variants ("Brazilian +/// Portuguese"); compounds are checked before bare names, so the latter returns `variant: +/// "Brazilian"` rather than a bare "Portuguese" match. Bare matches return `variant: ""`; +/// `None` means unrecognized input (never a guess). pub fn lang(text: &str) -> Option<LangInfo> { let lower = text.to_lowercase(); // Multi-word compounds first. Scan is positional (first hit wins), @@ -186,7 +183,6 @@ pub fn menu_lang(token: &str) -> Option<&'static str> { // ── ISO 639-1 → ISO 639-2 ──────────────────────────────────────────────────── // Complete ISO 639-1 set, paired with its ISO 639-2/T code (each code once). -// See docs/vocab.md#iso639_1_to_iso639_2--the-whole-iso-639-1-set for why /T. const ISO_639_1_TO_2: &[(&str, &str)] = &[ ("aa", "aar"), ("ab", "abk"), @@ -374,19 +370,16 @@ const ISO_639_1_TO_2: &[(&str, &str)] = &[ ("zu", "zul"), ]; -// Withdrawn ISO 639-1 codes DVD-Video still carries (frozen at the 1988 -// edition). See docs/vocab.md#iso639_1_to_iso639_2--the-whole-iso-639-1-set. +// Withdrawn ISO 639-1 codes DVD-Video still carries (frozen at the 1988 edition). const ISO_639_1_DEPRECATED: &[(&str, &str)] = &[("iw", "he"), ("in", "id"), ("ji", "yi")]; /// Map an ISO 639-1 two-letter language code to its ISO 639-2/T three-letter /// code, accepting the withdrawn DVD-era spellings (`iw`, `in`, `ji`) as /// aliases for their replacements. /// -/// Covers the WHOLE of ISO 639-1, unlike [`menu_lang`], whose table only -/// spans the languages seen in Blu-ray menu-graphic filenames. Case- -/// insensitive and trimmed. Returns `None` for anything that is not an ISO -/// 639-1 code — callers decide the fallback. See docs/vocab.md for why the -/// full set matters. +/// Covers the WHOLE of ISO 639-1, unlike [`menu_lang`], whose table only spans the languages +/// seen in Blu-ray menu-graphic filenames. Case- insensitive and trimmed. Returns `None` for +/// anything that is not an ISO 639-1 code — callers decide the fallback. pub fn iso639_1_to_iso639_2(code: &str) -> Option<&'static str> { let c = code.trim().to_ascii_lowercase(); let c = ISO_639_1_DEPRECATED @@ -446,7 +439,7 @@ pub fn purpose(text: &str) -> LabelPurpose { /// - "rnib", "descriptive service" → `DescriptiveService` /// - anything else → `None` /// -/// SDH wins over Forced when both are present. See docs/vocab.md for why. +/// SDH wins over Forced when both are present. pub fn qualifier(text: &str) -> LabelQualifier { let lower = text.to_lowercase(); if has_word(&lower, "sdh") || has_word(&lower, "captions") { @@ -463,8 +456,8 @@ pub fn qualifier(text: &str) -> LabelQualifier { // ── Internal: word-boundary matching ──────────────────────────────────────── -// True if `needle` appears in `haystack` at non-alphanumeric boundaries -// (`haystack` assumed lowercase). See docs/vocab.md#has_word--word-boundary-primitive. +// True if `needle` appears in `haystack` at non-alphanumeric boundaries (`haystack` assumed +// lowercase). fn has_word(haystack: &str, needle: &str) -> bool { if needle.is_empty() { return false; @@ -852,9 +845,8 @@ mod tests { assert_eq!(codec(""), ""); } - // Guards the `||` (not `&&`) in purpose()'s compound-phrase fast path: - // catches a substring match with no word boundary that has_word() would miss. - // See docs/vocab.md#test-rationale-mutation-testing-notes for the mutation trace. + // Guards the `||` (not `&&`) in purpose()'s compound-phrase fast path: catches a substring + // match with no word boundary that has_word() would miss. #[test] fn purpose_descriptive_service_substring_without_word_boundary() { assert_eq!( @@ -943,8 +935,8 @@ mod tests { assert_eq!(menu_lang(""), None); } - // `ISO_639_1_TO_2` structural invariants: complete 184-code set, distinct - // keys, well-formed values. See docs/vocab.md for the full rationale. + // `ISO_639_1_TO_2` structural invariants: complete 184-code set, distinct keys, well-formed + // values. #[test] fn iso639_1_table_is_complete_and_well_formed() { assert_eq!( diff --git a/src/labels/xml.rs b/src/labels/xml.rs index 76d0a2bd..40a6bb27 100644 --- a/src/labels/xml.rs +++ b/src/labels/xml.rs @@ -1,12 +1,11 @@ //! Tolerant XML scraping helpers — promoted from two near-duplicate //! hand-rolls in `paramount.rs` and `criterion.rs`. //! -//! Handles the subset of XML the BD-J authoring tools we've seen actually -//! emit: case-insensitive tag/attribute names, optional `ns:` namespace -//! prefixes, whitespace-tolerant `=` between attribute name and value, -//! both `"value"` and `'value'` quoting, and self-closing tags (`<tag />`, -//! `<tag/>`; [`text`] returns `Some("")` for empty content). Not a full -//! XML parser — see docs/labels-xml.md for scope and rationale. +//! Handles the subset of XML the BD-J authoring tools we've seen actually emit: +//! case-insensitive tag/attribute names, optional `ns:` namespace prefixes, whitespace-tolerant +//! `=` between attribute name and value, both `"value"` and `'value'` quoting, and self-closing +//! tags (`<tag />`, `<tag/>`; [`text`] returns `Some("")` for empty content). Not a full XML +//! parser. // Out of scope (intentionally simple): entity decoding, CDATA, comments, // processing instructions, DTD declarations — none of the BD-J authored diff --git a/src/lib.rs b/src/lib.rs index 72d7b5d6..dc9a2634 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,6 @@ //! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD. //! -//! Handles drive access, disc structure parsing, AACS decryption, and raw -//! sector reading. Architecture, AACS, and error code details: `docs/lib-overview.md`. +//! Handles drive access, disc structure parsing, AACS decryption, and raw sector reading. //! //! # Quick Start //! diff --git a/src/mpls.rs b/src/mpls.rs index 13c0c1d3..24d238e4 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -1,25 +1,9 @@ -//! MPLS playlist parser — Blu-ray movie playlists. +//! Blu-ray MPLS playlists: clips, in/out timestamps and stream tables. //! -//! Each .mpls file in BDMV/PLAYLIST/ defines a title. -//! Contains play items (clips) with in/out timestamps, -//! stream info (video, audio, subtitle tracks). -//! -//! Format is documented in the BD-ROM PlayList (MPLS) specification. -//! -//! ## Multi-angle PlayItem STN offset (issue #45) -//! The STN (stream-number) table sits at PlayItem offset 32 for a single-angle -//! item, but a MULTI-ANGLE item (is_multi_angle = bit 4 of `item[10]`) inserts an -//! angle block right after still_time at offset 32: -//! number_of_angles(1) + flags(1) + (number_of_angles − 1) angle references, -//! each clip_name(5) + clip_codec_id(4) + ref_to_STC_id(1) = 10 bytes -//! (the first angle is the primary clip already at `item[0..5]`). So the real STN -//! begins at `32 + 2 + (number_of_angles − 1) * 10`. A fixed offset of 32 lands -//! INSIDE that block and misreads the stream counts as garbage (e.g. n_video = -//! 51 on Spider-Man 3 UHD 00245/00246.mpls, number_of_angles = 2 ⇒ real offset -//! 44), dropping every stream — the primary video included. That makes -//! `DiscTitle::has_video()` return false, which drops a multi-angle/seamless- -//! branch FEATURE from the disc's nav candidate set (see `Disc::scan_with`) and -//! loses it in main-title selection. `parse` computes the offset accordingly. +//! The STN table begins at PlayItem offset 32 for single-angle items. Multi-angle +//! items insert a two-byte header and ten bytes per additional angle before STN, +//! so its offset is `32 + 2 + (number_of_angles - 1) * 10`. The primary angle's +//! clip is already present at the start of the PlayItem. use crate::error::{Error, Result}; @@ -1593,7 +1577,6 @@ mod tests { ); } - // See docs/mpls.md — STN Table Secondary Block Alignment (test coverage note) #[test] fn full_stn_table_block_alignment() { let mut entries: Vec<Vec<u8>> = vec![ diff --git a/src/mux/au_assembly.rs b/src/mux/au_assembly.rs index aee08be1..44b2c902 100644 --- a/src/mux/au_assembly.rs +++ b/src/mux/au_assembly.rs @@ -1,8 +1,7 @@ //! Access-unit assembly — a codec-parser helper. //! -//! Converts PES fragments to AU-complete access units for program streams, -//! which (unlike transport streams) do not align PES to AU boundaries. See -//! `docs/au-assembly.md` for the full rationale. +//! Converts PES fragments to AU-complete access units for program streams, which (unlike +//! transport streams) do not align PES to AU boundaries. //! //! [`AuAssembler`] is shared by every program-stream video parser instead of //! each hand-rolling the buffer: h264/hevc/vc1 ([`Mode::StartCode`] / @@ -125,9 +124,8 @@ pub(crate) struct AuAssembler { } impl AuAssembler { - // An assembler for `codec`. H.264/HEVC/VC-1 get a reassembling `Mode`; - // MPEG-2 (self-reassembles) and audio/subtitle codecs (self-framing) get - // `Mode::Passthrough`. See docs/au-assembly.md. + // An assembler for `codec`. H.264/HEVC/VC-1 get a reassembling `Mode`; MPEG-2 + // (self-reassembles) and audio/subtitle codecs (self-framing) get `Mode::Passthrough`. pub(crate) fn for_codec(codec: Codec) -> Self { let mode = match codec { Codec::H264 => Mode::StartCode(0x09), // access_unit_delimiter NAL (type 9) @@ -156,8 +154,7 @@ impl AuAssembler { } } - // An assembler that reassembles MPEG-2 access units, owned directly by - // the MPEG-2 parser. See docs/au-assembly.md. + // An assembler that reassembles MPEG-2 access units, owned directly by the MPEG-2 parser. pub(crate) fn mpeg2() -> Self { Self { mode: Mode::Mpeg2, @@ -174,9 +171,8 @@ impl AuAssembler { } } - // Feed one PES fragment the caller OWNS; return every AU now complete. - // Passthrough moves the payload in with no copy; buffering modes copy - // into `buf` exactly as `push`. See docs/au-assembly.md. + // Feed one PES fragment the caller OWNS; return every AU now complete. Passthrough moves + // the payload in with no copy; buffering modes copy into `buf` exactly as `push`. pub(crate) fn push_owned( &mut self, data: Vec<u8>, @@ -330,9 +326,8 @@ impl AuAssembler { out } - // Detach `buf[..end]` as the AU's own `Vec`, handing over the allocation - // rather than copying (falls back to a copy when `buf` is far larger - // than the AU). See docs/au-assembly.md. + // Detach `buf[..end]` as the AU's own `Vec`, handing over the allocation rather than + // copying (falls back to a copy when `buf` is far larger than the AU). fn take_front(&mut self, end: usize) -> Vec<u8> { let cap = self.buf.capacity(); let tail_len = self.buf.len() - end; @@ -435,9 +430,8 @@ impl AuAssembler { None } - // Retire every mark before `off`: the STREAM-START case, where bytes ahead - // of the first AU boundary predate sync and have no prior AU to be - // discontinuous from. See docs/au-assembly.md. + // Retire every mark before `off`: the STREAM-START case, where bytes ahead of the first AU + // boundary predate sync and have no prior AU to be discontinuous from. fn drop_marks_before(&mut self, off: u64) { while self.marks.front().is_some_and(|m| m.off < off) { self.marks.pop_front(); @@ -447,9 +441,8 @@ impl AuAssembler { } } - // Retire stale timing marks before `off` and record a GAP: the BACKSTOP - // case, where accumulated bytes had no AU start code and got discarded. - // See docs/au-assembly.md. + // Retire stale timing marks before `off` and record a GAP: the BACKSTOP case, where + // accumulated bytes had no AU start code and got discarded. fn discard_gap_before(&mut self, off: u64) { self.drop_marks_before(off); self.pending_gap = true; @@ -852,9 +845,9 @@ mod tests { ); } - // The 8 MiB backstop discards a start-code-free run; the AU that - // eventually emits MUST be marked discontinuous, or the resync gate - // never arms and a broken picture goes out silently. See docs/au-assembly.md. + // The 8 MiB backstop discards a start-code-free run; the AU that eventually emits MUST be + // marked discontinuous, or the resync gate never arms and a broken picture goes out + // silently. #[test] fn a_backstop_discard_marks_the_next_au_discontinuous() { let mut a = AuAssembler::for_codec(Codec::H264); @@ -894,9 +887,8 @@ mod tests { ); } - // The opposite case: bytes ahead of the FIRST delimiter predate sync and - // have no prior AU to be discontinuous from, so retiring the marks there - // is right. See docs/au-assembly.md. + // The opposite case: bytes ahead of the FIRST delimiter predate sync and have no prior AU + // to be discontinuous from, so retiring the marks there is right. #[test] fn a_stream_start_trim_does_not_mark_the_first_au_discontinuous() { let mut a = AuAssembler::for_codec(Codec::H264); @@ -916,9 +908,9 @@ mod tests { ); } - // A source-signalled discontinuity reaches the AU it opens: the - // `disc_marks` path, distinct from the sticky `pending_gap` the backstop - // sets. Deliberately kept separate from that test. See docs/au-assembly.md. + // A source-signalled discontinuity reaches the AU it opens: the `disc_marks` path, distinct + // from the sticky `pending_gap` the backstop sets. Deliberately kept separate from that + // test. #[test] fn a_source_signalled_discontinuity_reaches_the_au_it_opens() { let mut a = AuAssembler::for_codec(Codec::H264); @@ -960,9 +952,8 @@ mod tests { ); } - // MEASURED: a drained AU must be HANDED the accumulation buffer's - // allocation, not copied. The emitted `Vec`'s data pointer must equal the - // buffer's own pointer. See docs/au-assembly.md. + // MEASURED: a drained AU must be HANDED the accumulation buffer's allocation, not copied. + // The emitted `Vec`'s data pointer must equal the buffer's own pointer. #[test] fn drained_au_takes_over_the_buffer_allocation_without_copying() { let mut a = AuAssembler::for_codec(Codec::H264); @@ -1012,9 +1003,8 @@ mod tests { assert_eq!(a.buf.len(), 4, "the buffer holds only AU2's delimiter tail"); } - // MEASURED: `take_front`'s copy fallback must not become permanent — - // one copy is expected right after a size step down; a per-frame copy - // forever is the bug. See docs/au-assembly.md. + // MEASURED: `take_front`'s copy fallback must not become permanent — one copy is expected + // right after a size step down; a per-frame copy forever is the bug. #[test] fn handover_survives_a_large_au_instead_of_copying_every_later_one() { let mut a = AuAssembler::for_codec(Codec::H264); @@ -1039,9 +1029,8 @@ mod tests { ); } - // AU-opener detection pinned to normative byte values (drift check). The - // offset must be the real start code position, never a fixed 0, which - // would glue pre-sync junk onto an AU. See docs/au-assembly.md. + // AU-opener detection pinned to normative byte values (drift check). The offset must be the + // real start code position, never a fixed 0, which would glue pre-sync junk onto an AU. #[test] fn au_opener_from_locates_the_real_start_code_per_codec() { // Junk that contains a start-code PREFIX but no opener suffix, so a @@ -1146,9 +1135,8 @@ mod tests { ); } - // After pre-sync bytes are discarded, the emitted AU must take the timing - // of the fragment that ACTUALLY opened it, not the discarded junk's. - // See docs/au-assembly.md. + // After pre-sync bytes are discarded, the emitted AU must take the timing of the fragment + // that ACTUALLY opened it, not the discarded junk's. #[test] fn discarded_pre_sync_marks_do_not_time_the_first_access_unit() { let src = |b: u64| SourcePos { diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index 7850f5d2..85b483c8 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -7,13 +7,11 @@ //! groups syncframes into access units at each `substreamid`-0 independent //! substream rather than at every syncframe. Buffers across PES boundaries so //! access units that span two PES packets are emitted complete, never split. -// See docs/ac3.md — why a frame set is one sample and never split into tracks. use super::{CodecParser, Frame, PesPacket, pts_to_ns}; -// Sample rates by fscod (0=48kHz, 1=44.1kHz, 2=32kHz). fscod=3 is reserved in -// AC-3 / signals E-AC-3 "fscod2"; see docs/ac3.md for the fscod2 decode and -// why index 3 (48 kHz) is only a too-short-header fallback. +// Sample rates by fscod (0=48kHz, 1=44.1kHz, 2=32kHz). fscod=3 is reserved in AC-3 / signals +// E-AC-3 "fscod2". const SAMPLE_RATES: [u32; 4] = [48_000, 44_100, 32_000, 48_000]; /// E-AC-3 reduced sample rates indexed by fscod2 (byte-4 bits `[5:4]`), used when @@ -28,9 +26,8 @@ const MIN_FRAME_BYTES: usize = 6; /// AC-3 (legacy) always carries 6 audio blocks × 256 samples = 1536 samples. const AC3_SAMPLES_PER_FRAME: u32 = 1536; -// Hard cap on the carry-over buffer: one worst-case straddling frame set -// (72 × 8192-byte syncframes, Annex E) plus slack. See docs/ac3.md for the -// derivation; past this we drop and resync rather than accumulate forever. +// Hard cap on the carry-over buffer: one worst-case straddling frame set (72 × 8192-byte +// syncframes, Annex E) plus slack. const MAX_AC3_BUF: usize = 1024 * 1024; pub struct Ac3Parser { @@ -107,9 +104,8 @@ impl Ac3Parser { self.tally.dropped_duration_ns() } - // Scan `data` for (E-)AC-3 syncframes and group them into access units, - // closing each only at the next `substreamid`-0 independent substream. - // See docs/ac3.md — frame-set closing rule, PTS-anchor semantics, returns. + // Scan `data` for (E-)AC-3 syncframes and group them into access units, closing each only + // at the next `substreamid`-0 independent substream. fn scan_access_units( &mut self, data: &[u8], @@ -397,9 +393,8 @@ enum SubstreamRole { Extends, } -// Classify a syncframe for access-unit assembly: byte 2's strmtyp/substreamid -// (ETSI TS 102 366 Annex E BSI) decide Starts vs Extends. See docs/ac3.md for -// the full boundary rule (why substreamid==0, not strmtyp alone, is the key). +// Classify a syncframe for access-unit assembly: byte 2's strmtyp/substreamid (ETSI TS 102 366 +// Annex E BSI) decide Starts vs Extends. fn substream_role(data: &[u8], bsid: u8) -> SubstreamRole { if bsid < 11 || data.len() < 3 { return SubstreamRole::Starts; @@ -417,9 +412,8 @@ fn substream_role(data: &[u8], bsid: u8) -> SubstreamRole { use super::crc::crc16_ansi; -// Whether a fully-buffered (E-)AC-3 frame passes its native CRC-16/ANSI over -// the bytes after the syncword. `frame` must be exactly syncword..frame_size; -// see docs/ac3.md for the CRC layout and why a mismatch drops the frame. +// Whether a fully-buffered (E-)AC-3 frame passes its native CRC-16/ANSI over the bytes after +// the syncword. `frame` must be exactly syncword..frame_size. fn frame_crc_ok(frame: &[u8]) -> bool { // Need the syncword (2) plus at least one covered byte; the caller only // invokes this on a fully-sized frame, so this is defensive. @@ -596,13 +590,12 @@ fn frame_duration_ns(data: &[u8], bsid: u8) -> u64 { (samples * 1_000_000_000 + rate / 2) / rate } -// Base channel count per AC-3 `acmod` (A/52 Table 5.8), BEFORE the LFE; add 1 -// when `lfeon` is set. See docs/ac3.md for the per-acmod channel layout table. +// Base channel count per AC-3 `acmod` (A/52 Table 5.8), BEFORE the LFE; add 1 when `lfeon` is +// set. const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5]; -// Decode the channel count of an (E-)AC-3 frame from its bitstream `acmod` + -// `lfeon` (A/52 §5.3.2 BSI) — the AUTHORITATIVE count over the unreliable DVD -// IFO nibble. See docs/ac3.md for the bit layout `acmod`/`lfeon` are read from. +// Decode the channel count of an (E-)AC-3 frame from its bitstream `acmod` + `lfeon` (A/52 +// §5.3.2 BSI) — the AUTHORITATIVE count over the unreliable DVD IFO nibble. pub(crate) fn acmod_channels(data: &[u8]) -> Option<u8> { // Need at least bytes 0..=6 to read acmod (byte 6) and its trailing // optional fields + lfeon (which never spills past byte 7 for any acmod). @@ -759,9 +752,8 @@ mod tests { frame[n - 1] = (c & 0xFF) as u8; } - // The per-PES working buffer must be REUSED, not reallocated (`parse` - // runs ~10^5 times per audio track). See docs/ac3.md for why the capacity - // assert is deliberately white-box. + // The per-PES working buffer must be REUSED, not reallocated (`parse` runs ~10^5 times per + // audio track). #[test] fn the_working_buffer_is_reused_across_packets_not_reallocated() { let mut parser = Ac3Parser::new(); @@ -1741,9 +1733,9 @@ mod tests { // --- E-AC-3 substream grouping: one access unit per independent substream --- - /// Builds a synthetic E-AC-3 syncframe of exactly `size` bytes with the - /// given strmtyp/substreamid; fscod/numblkscod/acmod fixed at 48kHz/6 - /// blocks/5.1, bsid 16, CRC finalized. See docs/ac3.md for the byte layout. + /// Builds a synthetic E-AC-3 syncframe of exactly `size` bytes with the given + /// strmtyp/substreamid; fscod/numblkscod/acmod fixed at 48kHz/6 blocks/5.1, bsid 16, CRC + /// finalized. fn make_eac3_frame(strmtyp: u8, substreamid: u8, size: usize) -> Vec<u8> { assert!(size >= MIN_FRAME_BYTES && size.is_multiple_of(2)); let frmsiz = size / 2 - 1; @@ -2104,9 +2096,8 @@ mod tests { f } - // A held access unit's carry-over bytes must not be re-scanned/re-CRCed - // from byte 0 on every packet (quadratic work). See docs/ac3.md for the - // frames_scanned measurement and the mutation this catches. + // A held access unit's carry-over bytes must not be re-scanned/re-CRCed from byte 0 on + // every packet (quadratic work). #[test] fn a_held_access_unit_is_not_rescanned_from_its_first_frame_every_packet() { const DEPENDENTS: usize = 200; @@ -2142,9 +2133,8 @@ mod tests { ); } - // A concealed gap must drop the HELD access unit, not just the byte - // buffer: its offsets describe the pre-gap bytes and must not be applied - // to the unrelated post-gap ones. See docs/ac3.md for the full rationale. + // A concealed gap must drop the HELD access unit, not just the byte buffer: its offsets + // describe the pre-gap bytes and must not be applied to the unrelated post-gap ones. #[test] fn a_discontinuity_drops_the_held_access_unit_with_its_bytes() { let mut parser = Ac3Parser::new(); @@ -2234,9 +2224,8 @@ mod tests { "the unit belongs to the packet its FIRST byte came from" ); } - // A track that becomes POISONED while an access unit is held open across - // a PES boundary must not emit that unit on resume. See docs/ac3.md for - // the mutation this catches and the fixture's interleaving. + // A track that becomes POISONED while an access unit is held open across a PES boundary + // must not emit that unit on resume. #[test] fn a_held_access_unit_is_dropped_when_the_track_poisons_before_it_resumes() { // One more than the verdict gate: the Nth close is what poisons. diff --git a/src/mux/codec/adts.rs b/src/mux/codec/adts.rs index 8e5d9ac7..c6764a5d 100644 --- a/src/mux/codec/adts.rs +++ b/src/mux/codec/adts.rs @@ -1,6 +1,4 @@ //! AAC ADTS framing and validation. -//! -//! See docs/adts.md for framing, timestamp and validation rules. use super::audio_frames::{AudioFrames, Header}; #[cfg(test)] @@ -130,8 +128,8 @@ mod tests { } } - // A header claiming a CRC (protection_absent=0) but declaring a frame - // length too short to contain one. See docs/adts.md for full rationale. + // A header claiming a CRC (protection_absent=0) but declaring a frame length too short to + // contain one. #[test] fn a_crc_present_header_shorter_than_its_own_crc_is_invalid() { for declared in [7u32, 8] { @@ -210,8 +208,8 @@ mod tests { ); } - // A dropped frame's header failed validation, so duration is reported as - // zero rather than guessed from untrustworthy fields. See docs/adts.md. + // A dropped frame's header failed validation, so duration is reported as zero rather than + // guessed from untrustworthy fields. #[test] fn dropped_frames_are_counted_but_their_duration_is_not_invented() { let mut parser = AdtsParser::new(); diff --git a/src/mux/codec/coding.rs b/src/mux/codec/coding.rs index a7cf43e2..d0f7d9cc 100644 --- a/src/mux/codec/coding.rs +++ b/src/mux/codec/coding.rs @@ -7,7 +7,6 @@ //! codec-agnostic accessors ([`coding_type`](PictureInfo::coding_type), //! [`field_order`](PictureInfo::field_order), [`nb_fields`](PictureInfo::nb_fields), //! [`progressive`](PictureInfo::progressive)); adding a codec adds a `CodingDetail` arm. -// See docs/coding.md — spec references (H.273, ISO/IEC 13818-2 §6.3.10, RFC 9559 §5.1.4.1.28). /// Coding/prediction type of a coded picture, mapped to the three families the /// muxer cares about (cue/keyframe marking, B-frame ordering). Each codec maps @@ -73,7 +72,6 @@ pub enum CodingDetail { CodingTypeOnly, } -// See docs/coding.md — HDR10 unit-scaling rationale and H.265 SEI spec refs (D.2.28, D.2.35). /// HDR10 static metadata measured from a video bitstream (HEVC SEI). Carried on /// [`PictureInfo`] as the per-stream colour-volume signalling: it only ever /// reaches the muxer when BOTH SEI messages were actually present in the stream, diff --git a/src/mux/codec/crc.rs b/src/mux/codec/crc.rs index e4e89d01..ee1c3a2c 100644 --- a/src/mux/codec/crc.rs +++ b/src/mux/codec/crc.rs @@ -26,9 +26,8 @@ pub(crate) fn crc16_ansi(data: &[u8]) -> u16 { crc } -// CRC-16, poly 0x002D, init 0, MSB-first — MLP/TrueHD major-sync checksum. -// Emits bytes in reversed order vs a standard little-endian CRC readout; -// see docs/crc.md for the swap-and-compare details and verification history. +// CRC-16, poly 0x002D, init 0, MSB-first — MLP/TrueHD major-sync checksum. Emits bytes in +// reversed order vs a standard little-endian CRC readout. pub(crate) fn crc16_mlp(data: &[u8]) -> u16 { let mut crc: u16 = 0; for &b in data { diff --git a/src/mux/codec/dropgate.rs b/src/mux/codec/dropgate.rs index 137837e7..e9b6c5a4 100644 --- a/src/mux/codec/dropgate.rs +++ b/src/mux/codec/dropgate.rs @@ -5,9 +5,6 @@ //! Responsibilities: count kept/dropped AUs and dropped duration; log every //! drop (per-drop trace plus a once-per-track `warn` aggregate); and latch a //! poison flag once a track is judged mostly undecodable so the rest drops too. -//! -//! See `docs/dropgate.md` for full rationale (per-codec detection, sync -//! preservation, TrueHD collateral drops). /// Minimum access units observed before the whole-track drop verdict can fire. /// Below this, a short damaged burst can't poison an otherwise-good track. @@ -73,9 +70,9 @@ impl DropTally { self.maybe_poison(); } - // Collateral: caused by another AU's corruption (TrueHD resync-forward, or - // an already-poisoned track), not individually verified undecodable. - // Deliberately excluded from the poison verdict — see docs/dropgate.md. + // Collateral: caused by another AU's corruption (TrueHD resync-forward, or an + // already-poisoned track), not individually verified undecodable. Deliberately excluded + // from the poison verdict. pub(crate) fn record_collateral_drop( &mut self, pts_ns: i64, @@ -101,8 +98,8 @@ impl DropTally { ); } - // Whole-track fallback: past the min-AU gate, >50% dropped latches `poisoned` - // and logs once. See docs/dropgate.md for the full rationale. + // Whole-track fallback: past the min-AU gate, >50% dropped latches `poisoned` and logs + // once. fn maybe_poison(&mut self) { if self.poisoned { return; @@ -179,9 +176,8 @@ mod tests { assert!(!t.is_poisoned()); } - // Puts the kept count on the critical path (unlike the test above, which - // never exercises it): losing it would silently discard a healthy track. - // See docs/dropgate.md for the full rationale. + // Puts the kept count on the critical path (unlike the test above, which never exercises + // it): losing it would silently discard a healthy track. #[test] fn interleaved_keeps_are_in_the_poison_denominator() { let mut t = DropTally::new("test"); diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index 9871484d..3dfad201 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -9,9 +9,7 @@ use super::startcode::BitReader; use super::{CodecParser, Frame, PesPacket}; const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01]; -// DTS-HD extension syncword. See docs/dts.md — DTS_HD_EXT_SYNC: exact -// EXSS sizing keeps a false core sync inside its payload from splitting -// the access unit. +// DTS-HD extension syncword. const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25]; /// DTS / DTS-HD elementary-stream parser. Buffers DTS across PES boundaries so @@ -78,9 +76,6 @@ impl DtsParser { self.tally.dropped_duration_ns() } - // See docs/dts.md — emit_or_drop: gates an AU through the decodability - // check. The PTS clock is already advanced either way, so a drop is a - // gap, never a shift; every drop is logged (fail-loud). fn emit_or_drop( &mut self, au: Vec<u8>, @@ -116,9 +111,6 @@ impl DtsParser { } } - // See docs/dts.md — stamp_pts: re-base to each PES's own PTS, advancing - // by one frame duration only within a run sharing that PES (fixes DVD's - // several-cores-per-PES case without the drift a global clock caused). fn stamp_pts(&mut self, front: i64, dur_ns: i64) -> i64 { let base = if front != PTS_UNSET && front != self.last_front_pts { // New PES (or the first AU): trust its own timestamp — no drift. @@ -179,14 +171,13 @@ impl DtsParser { /// this without a clean boundary we resync rather than stall or balloon. const MAX_AU_BYTES: usize = 65536; -// "Enough bytes to read the fsize field" — a HEADER-LAYOUT minimum, distinct -// from MIN_CORE_FRAME_BYTES (the decoded-size validity floor). See docs/dts.md -// — CORE_HEADER_MIN_BYTES. +// "Enough bytes to read the fsize field" — a HEADER-LAYOUT minimum, distinct from +// MIN_CORE_FRAME_BYTES (the decoded-size validity floor). const CORE_HEADER_MIN_BYTES: usize = 10; -// ETSI TS 102 114 on-wire FSIZE floor is 95, so a real core frame is at -// least 96 bytes; a smaller decoded size means a false/corrupt sync, so we -// resync rather than close an AU at a junk boundary. See docs/dts.md. +// ETSI TS 102 114 on-wire FSIZE floor is 95, so a real core frame is at least 96 bytes; a +// smaller decoded size means a false/corrupt sync, so we resync rather than close an AU at a +// junk boundary. const MIN_CORE_FRAME_BYTES: usize = 96; // Sentinel for "no valid PTS base captured yet": real PTS-in-ns values are @@ -354,8 +345,8 @@ impl CodecParser for DtsParser { } impl DtsParser { - // Emits the final buffered AU (core + extensions, gated through the - // decodability check) at end of stream. See docs/dts.md — flush_tail. + // Emits the final buffered AU (core + extensions, gated through the decodability check) at + // end of stream. fn flush_tail(&mut self) -> Vec<Frame> { if find_sync(self.acc.as_slice(), &DTS_CORE_SYNC) != Some(0) || self.acc.len() < CORE_HEADER_MIN_BYTES @@ -387,12 +378,11 @@ impl DtsParser { } } -/// Offset where the FINAL access unit ends at end-of-stream, dropping a -/// truncated trailing core. Trailing DTS-HD extension substreams belong to this -/// AU and are skipped precisely (kept); a trailing CORE sync — complete or -/// truncated — begins a NEW AU that never closed during `parse()`, so the AU -/// ends there. A tail too short to identify, or non-sync garbage, is kept with -/// the AU. See docs/dts.md. +/// Offset where the FINAL access unit ends at end-of-stream, dropping a truncated trailing +/// core. Trailing DTS-HD extension substreams belong to this AU and are skipped precisely +/// (kept); a trailing CORE sync — complete or truncated — begins a NEW AU that never closed +/// during `parse()`, so the AU ends there. A tail too short to identify, or non-sync garbage, +/// is kept with the AU. fn final_au_end(buf: &[u8], core_size: usize) -> usize { let mut pos = core_size; loop { @@ -444,14 +434,11 @@ enum NextCore { None, } -// Byte length shared by both 32-bit DTS syncwords (core and EXSS). See -// docs/dts.md — SYNCWORD_BYTES / next_core_boundary for how candidate core -// syncs found in extension payload are validated by decoded size. +// Byte length shared by both 32-bit DTS syncwords (core and EXSS). const SYNCWORD_BYTES: usize = DTS_CORE_SYNC.len(); -// EXSS header field bit widths (ETSI TS 102 114). `bHeaderSizeType` selects -// short form (`nuExtSSHeaderSize` 8b, `nuExtSSFsize` 16b) or long (12/20b). -// See docs/dts.md. +// EXSS header field bit widths (ETSI TS 102 114). `bHeaderSizeType` selects short form +// (`nuExtSSHeaderSize` 8b, `nuExtSSFsize` 16b) or long (12/20b). const EXSS_USER_DEFINED_BITS: u32 = 8; const EXSS_INDEX_BITS: u32 = 2; const EXSS_HEADER_SIZE_TYPE_BITS: u32 = 1; @@ -471,9 +458,9 @@ const EXSS_HEADER_MIN_BYTES: usize = SYNCWORD_BYTES + EXSS_FSIZE_BITS_LONG) .div_ceil(u8::BITS) as usize; -// EXSS total byte size (including the sync), read precisely from its -// header; `buf` must begin with DTS_HD_EXT_SYNC. Lets the AU framer skip -// the extension exactly rather than scanning its payload. See docs/dts.md. +// EXSS total byte size (including the sync), read precisely from its header; `buf` must begin +// with DTS_HD_EXT_SYNC. Lets the AU framer skip the extension exactly rather than scanning its +// payload. fn exss_frame_size(buf: &[u8]) -> Option<usize> { if buf.len() < EXSS_HEADER_MIN_BYTES { return None; @@ -492,9 +479,9 @@ fn exss_frame_size(buf: &[u8]) -> Option<usize> { Some(fsize_minus_one as usize + 1) } -// Offset where the current AU ends (start of the next core frame): trailing -// extensions are skipped PRECISELY by declared size, so a false core sync -// in XLL payload can't be mistaken for the boundary. See docs/dts.md. +// Offset where the current AU ends (start of the next core frame): trailing extensions are +// skipped PRECISELY by declared size, so a false core sync in XLL payload can't be mistaken for +// the boundary. fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore { let mut pos = core_size; loop { @@ -536,9 +523,8 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore { } } -// Heuristic fallback (pre-fix behaviour): scan for the next core syncword -// whose decoded size is plausible, used only when precise extension -// skipping can't proceed. See docs/dts.md. +// Heuristic fallback (pre-fix behaviour): scan for the next core syncword whose decoded size is +// plausible, used only when precise extension skipping can't proceed. fn scan_for_next_core(buf: &[u8], from: usize, ext_clean: bool) -> NextCore { let mut from = from; while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) { @@ -558,9 +544,9 @@ fn scan_for_next_core(buf: &[u8], from: usize, ext_clean: bool) -> NextCore { NextCore::None } -// `fsize` (14 bits, header bits 46-59) is length-minus-one on the wire; -// returns `fsize + 1`, the core length in bytes. `0` if `data` is short — -// every caller rejects that via the MIN floor. See docs/dts.md. +// `fsize` (14 bits, header bits 46-59) is length-minus-one on the wire; returns `fsize + 1`, +// the core length in bytes. `0` if `data` is short — every caller rejects that via the MIN +// floor. fn dts_core_frame_size(data: &[u8]) -> usize { if data.len() < CORE_HEADER_MIN_BYTES { return 0; @@ -584,9 +570,8 @@ const DTS_CORE_SAMPLE_RATES: [u32; 16] = [ 12_000, 24_000, 48_000, 96_000, 192_000, ]; -// Samples per DTS core frame: `(NBLKS + 1) * 32`. NBLKS (7 bits, ETSI TS -// 102 114) = byte4 bit0 + byte5 bits7-2, after FTYPE/SHORT/CPF. See -// docs/dts.md. +// Samples per DTS core frame: `(NBLKS + 1) * 32`. NBLKS (7 bits, ETSI TS 102 114) = byte4 bit0 +// + byte5 bits7-2, after FTYPE/SHORT/CPF. fn dts_core_samples(data: &[u8]) -> u32 { if data.len() < CORE_HEADER_MIN_BYTES { return 512; // typical; only reached on a truncated header @@ -614,20 +599,17 @@ fn dts_core_duration_ns(data: &[u8]) -> u64 { (samples * 1_000_000_000 + rate / 2) / rate } -// DTS core-header validity constants (ETSI TS 102 114): a NORMAL frame's -// `deficit_samples` must equal this; `npcmblocks` a multiple of -// DTS_SUBBAND_SAMPLES. See docs/dts.md. +// DTS core-header validity constants (ETSI TS 102 114): a NORMAL frame's `deficit_samples` must +// equal this; `npcmblocks` a multiple of DTS_SUBBAND_SAMPLES. const DTS_PCMBLOCK_SAMPLES: u32 = 32; const DTS_SUBBAND_SAMPLES: u32 = 8; -// Number of LEGAL 6-bit AMODE codes (ETSI TS 102 114 §5.3.1): 0-15 are -// defined channel arrangements (10-15 are 6/7/8-ch), only 16-63 are -// reserved. See docs/dts.md. +// Number of LEGAL 6-bit AMODE codes (ETSI TS 102 114 §5.3.1): 0-15 are defined channel +// arrangements (10-15 are 6/7/8-ch), only 16-63 are reserved. const DTS_AMODE_COUNT: u32 = 16; const DTS_LFE_FLAG_INVALID: u32 = 3; -// Sample rate (Hz) per core SFREQ code (ETSI TS 102 114 Table 6-4); `0` -// marks a reserved code (fails validation). Reserved: {0, 4, 5, 9, 10}. -// See docs/dts.md. +// Sample rate (Hz) per core SFREQ code (ETSI TS 102 114 Table 6-4); `0` marks a reserved code +// (fails validation). Reserved: {0, 4, 5, 9, 10}. const DTS_CORE_SR_VALID: [u32; 16] = [ 0, 8_000, 16_000, 32_000, 0, 0, 11_025, 22_050, 44_100, 0, 0, 12_000, 24_000, 48_000, 96_000, 192_000, @@ -669,9 +651,9 @@ impl DropReason { } } -// Decodability gate: ETSI TS 102 114 core-header validity checks. `Some` -// means genuinely undecodable; `None` also covers a truncated header -// (never false-drop our own buffer underrun). See docs/dts.md. +// Decodability gate: ETSI TS 102 114 core-header validity checks. `Some` means genuinely +// undecodable; `None` also covers a truncated header (never false-drop our own buffer +// underrun). fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> { let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?); @@ -802,8 +784,8 @@ mod tests { // --- stamp_pts: the "same PES, no fresh front timestamp" branch --- - // Third arm: front == last_front_pts, no cursor yet — must stamp the - // repeated front, not collapse to 0. See docs/dts.md. + // Third arm: front == last_front_pts, no cursor yet — must stamp the repeated front, not + // collapse to 0. #[test] fn stamp_pts_reuses_front_when_no_running_cursor_yet() { let mut parser = DtsParser::new(); @@ -818,8 +800,8 @@ mod tests { // --- drain_front: collapsing duplicate offset-0 PTS markers must not leak --- - // `drain_front` must collapse repeated offset-0 PTS markers each time, or - // `pts_marks` grows unbounded across a multi-hour track. See docs/dts.md. + // `drain_front` must collapse repeated offset-0 PTS markers each time, or `pts_marks` grows + // unbounded across a multi-hour track. #[test] fn drain_front_collapses_offset_zero_markers_instead_of_leaking() { let mut parser = DtsParser::new(); @@ -840,9 +822,8 @@ mod tests { ); } - // A real EXSS substream (short header form) with an optional false core - // sync embedded in its payload, to prove precise sizing bounds it. See - // docs/dts.md. + // A real EXSS substream (short header form) with an optional false core sync embedded in + // its payload, to prove precise sizing bounds it. fn make_exss(total: usize, false_core_at: Option<usize>) -> Vec<u8> { let mut d = vec![0u8; total]; d[0..4].copy_from_slice(&DTS_HD_EXT_SYNC); @@ -1249,9 +1230,8 @@ mod tests { ); } - // Minimal EXSS substream (sync + zero-padding); the parser delimits by - // the next CORE sync, not this extension's own size header. See - // docs/dts.md. + // Minimal EXSS substream (sync + zero-padding); the parser delimits by the next CORE sync, + // not this extension's own size header. fn make_dts_ext(size: usize) -> Vec<u8> { let mut e = vec![0u8; size]; e[0..4].copy_from_slice(&DTS_HD_EXT_SYNC); @@ -1328,9 +1308,8 @@ mod tests { assert_eq!(tail[0].data.len(), 512 + 300); } - // A core sync whose `fsize` decodes tiny (< MIN_CORE_FRAME_BYTES): must be - // drained and resynced past, not close an AU at a junk boundary. See - // docs/dts.md. + // A core sync whose `fsize` decodes tiny (< MIN_CORE_FRAME_BYTES): must be drained and + // resynced past, not close an AU at a junk boundary. fn bogus_tiny_core_sync() -> Vec<u8> { // Core sync + zero header bytes. fsize = 0 → core_size = 1 (< 10). let mut v = vec![0u8; 10]; @@ -1593,8 +1572,8 @@ mod tests { // --- next_core_boundary: SYNCWORD_BYTES length guard --- - // The `buf.len() < pos + SYNCWORD_BYTES` guard must be strict `<`: at - // exactly that length the sync IS fully present. See docs/dts.md. + // The `buf.len() < pos + SYNCWORD_BYTES` guard must be strict `<`: at exactly that length + // the sync IS fully present. #[test] fn next_core_boundary_exact_syncword_length_is_not_need_more() { let core = make_dts_core(MIN_CORE_FRAME_BYTES); @@ -1818,9 +1797,8 @@ mod tests { ); } - // A structurally-valid but UNDECODABLE core (LFE flag = reserved 3): - // sizes/syncs normally, fails the header validity check. See - // docs/dts.md. + // A structurally-valid but UNDECODABLE core (LFE flag = reserved 3): sizes/syncs normally, + // fails the header validity check. fn make_bad_dts_core(size: usize) -> Vec<u8> { let mut d = make_dts_core(size); assert!( @@ -2099,9 +2077,8 @@ mod tests { ); } - // Real-data fixture (ignored): re-parses a raw .dts stream and writes the - // emitted AUs back out for validation against an external decoder. See - // docs/dts.md. Env: DTS_IN, DTS_OUT. + // Real-data fixture (ignored): re-parses a raw.dts stream and writes the emitted AUs back + // out for validation against an external decoder. #[test] #[ignore] fn reparse_real_dts_file() { @@ -2151,8 +2128,8 @@ mod tests { // ── Exact-boundary behaviour of the AU framer ──────────────────────────── - // `find_sync` scans `0..=len-4`; the `len == 4` boundary (where a split - // sync lands on its last byte) isn't covered elsewhere. See docs/dts.md. + // `find_sync` scans `0..=len-4`; the `len == 4` boundary (where a split sync lands on its + // last byte) isn't covered elsewhere. #[test] fn find_sync_matches_a_buffer_that_is_exactly_the_syncword() { assert_eq!( @@ -2169,9 +2146,8 @@ mod tests { ); } - // Exactly CORE_HEADER_MIN_BYTES must be decoded, not deferred — else a - // false sync sits at the front, blocking every later real core. See - // docs/dts.md. + // Exactly CORE_HEADER_MIN_BYTES must be decoded, not deferred — else a false sync sits at + // the front, blocking every later real core. #[test] fn a_core_header_of_exactly_the_minimum_length_is_decoded_not_deferred() { let mut parser = DtsParser::new(); @@ -2215,9 +2191,8 @@ mod tests { ); } - // The flush guard is a DISJUNCTION: a buffer not BEGINNING with a core - // sync is discarded regardless of length, or junk could size-decode into - // a fake AU. See docs/dts.md. + // The flush guard is a DISJUNCTION: a buffer not BEGINNING with a core sync is discarded + // regardless of length, or junk could size-decode into a fake AU. #[test] fn flush_discards_a_long_buffer_that_does_not_begin_with_a_core_sync() { // A well-formed core frame with ONE byte of its syncword corrupted: every @@ -2247,8 +2222,8 @@ mod tests { assert!(parser.flush().is_empty(), "a bare sync tail is not an AU"); } - // EXSS_HEADER_MIN_BYTES is the WORST-CASE header length, pinned at both - // sides of the boundary rather than by value. See docs/dts.md. + // EXSS_HEADER_MIN_BYTES is the WORST-CASE header length, pinned at both sides of the + // boundary rather than by value. #[test] fn exss_frame_size_needs_the_worst_case_header_and_no_more() { assert_eq!( @@ -2273,9 +2248,8 @@ mod tests { ); } - // The point of the shared buffer: an AU whose core arrived EARLIER keeps - // that packet's source offset, not the completing packet's. See - // docs/dts.md. + // The point of the shared buffer: an AU whose core arrived EARLIER keeps that packet's + // source offset, not the completing packet's. #[test] fn an_access_unit_carries_the_source_of_the_packet_its_core_arrived_in() { let mut parser = DtsParser::new(); diff --git a/src/mux/codec/dvdsub.rs b/src/mux/codec/dvdsub.rs index 7247c5a9..38f6e239 100644 --- a/src/mux/codec/dvdsub.rs +++ b/src/mux/codec/dvdsub.rs @@ -8,7 +8,6 @@ //! subtitles aren't split/garbled, inheriting the head PES's PTS. //! //! For MKV: codec ID "S_VOBSUB". All frames are keyframes. -// See docs/dvdsub-mod.md — why PTS presence, not `pending`, is the SPU boundary. use super::{CodecParser, Frame, PesPacket, pts_to_ns}; @@ -146,7 +145,6 @@ impl CodecParser for DvdSubParser { // ── YCbCr → RGB conversion and palette formatting ───────────────────────── // Byte-2-as-Cb is invisible on achromatic (Cb=Cr=128) palette entries. -// See docs/dvdsub-mod.md for the full derivation. /// Convert a single YCbCr color to RGB, clamping to [0, 255]. /// /// Input: `[padding, Y, Cr, Cb]` (as stored in DVD IFO PGC data). Note the @@ -155,8 +153,7 @@ impl CodecParser for DvdSubParser { /// /// Returns `[R, G, B]`. /// -/// Uses full-range (JFIF) BT.601 coefficients, matching the VobSub `.idx` -/// on-disk convention (see docs/dvdsub-mod.md for why not studio-swing). +/// Uses full-range (JFIF) BT.601 coefficients, matching the VobSub `.idx` on-disk convention. pub fn ycbcr_to_rgb(color: &[u8; 4]) -> [u8; 3] { let y = color[1] as f64; let cr = color[2] as f64; @@ -443,9 +440,8 @@ mod tests { assert!(b < 30, "B should be low for red, got {}", b); } - // Real on-disc red entry (byte 2=Cr, byte 3=Cb); catches a chroma-byte - // swap. `_white`/`_black` can't catch this (Cb=Cr=128 is a no-op swap). - // See docs/dvdsub-mod.md for the full derivation. + // Real on-disc red entry (byte 2=Cr, byte 3=Cb); catches a chroma-byte swap. + // `_white`/`_black` can't catch this (Cb=Cr=128 is a no-op swap). #[test] fn ycbcr_to_rgb_reads_byte2_as_cr_and_byte3_as_cb() { // On-disc [pad, Y, Cr, Cb] for saturated red. diff --git a/src/mux/codec/flac.rs b/src/mux/codec/flac.rs index 4bb29f57..d207a5fb 100644 --- a/src/mux/codec/flac.rs +++ b/src/mux/codec/flac.rs @@ -6,8 +6,6 @@ //! has zero residue (RFC 9639, frame footer). Nonzero residue → corruption: //! drop the frame (silence gap, PTS preserved), logged via the shared tally. //! A packet without the FLAC sync passes through unchanged (never false-dropped). -//! -//! See docs/flac.md for the raw-stream-vs-container-delimited framing rationale. use super::crc::crc16_ansi; use super::dropgate::DropTally; diff --git a/src/mux/codec/h264.rs b/src/mux/codec/h264.rs index 73af00d9..89776c8c 100644 --- a/src/mux/codec/h264.rs +++ b/src/mux/codec/h264.rs @@ -16,8 +16,8 @@ const NAL_SPS: u8 = 7; const NAL_PPS: u8 = 8; const NAL_AUD: u8 = 9; -// Map an H.264 slice_type (§7.4.3, Table 7-6) to a coding type via -// slice_type % 5 (values 5..=9 repeat 0..=4). See docs/h264.md — slice-type mapping. +// Map an H.264 slice_type (§7.4.3, Table 7-6) to a coding type via slice_type % 5 (values 5..=9 +// repeat 0..=4). fn h264_slice_coding_type(slice_type: u32) -> Option<CodingType> { match slice_type { 0..=9 => Some(match slice_type % 5 { @@ -115,15 +115,13 @@ thread_local! { } // Copy the leading bytes of an EBSP with emulation-prevention bytes removed. -// See docs/h264.md — unescape_ebsp_prefix. fn unescape_ebsp_prefix(ebsp: &[u8]) -> Vec<u8> { const PREFIX_OCTETS: usize = 16; unescape_ebsp(ebsp, PREFIX_OCTETS) } -// Copy `ebsp` with emulation-prevention bytes removed (cumulative zero-run -// rule, ITU-T H.264 §7.3.1), stopping after `max_octets` output bytes. -// See docs/h264.md — unescape_ebsp. +// Copy `ebsp` with emulation-prevention bytes removed (cumulative zero-run rule, ITU-T H.264 +// §7.3.1), stopping after `max_octets` output bytes. fn unescape_ebsp(ebsp: &[u8], max_octets: usize) -> Vec<u8> { let mut out = Vec::with_capacity(max_octets.min(ebsp.len())); let mut zeros = 0usize; @@ -153,9 +151,9 @@ fn push_length_prefixed(out: &mut Vec<u8>, nal: &[u8]) { out.extend_from_slice(nal); } -// Handle an SPS/PPS NAL: strip/emit decision is against the ACTIVE set `cur`, -// not codecPrivate `first`, so a switch back to the first-seen body is still -// told to the decoder. Returns true when emitted in-band. See docs/h264.md. +// Handle an SPS/PPS NAL: strip/emit decision is against the ACTIVE set `cur`, not codecPrivate +// `first`, so a switch back to the first-seen body is still told to the decoder. Returns true +// when emitted in-band. fn handle_param_set( first: &mut Option<Vec<u8>>, cur: &mut Option<Vec<u8>>, @@ -177,9 +175,9 @@ fn handle_param_set( true } -// Append the active parameter set `cur` to `prefix` (length-prefixed) so every -// keyframe is self-contained. Unconditional (not only on change) so a decoder -// that silently dropped a param set is self-healing. See docs/h264.md. +// Append the active parameter set `cur` to `prefix` (length-prefixed) so every keyframe is +// self-contained. Unconditional (not only on change) so a decoder that silently dropped a param +// set is self-healing. fn reassert_active(prefix: &mut Vec<u8>, cur: &Option<Vec<u8>>, emitted: bool) { if emitted { return; @@ -395,9 +393,8 @@ impl CodecParser for H264Parser { } } -// Parse (chroma_format_idc, bit_depth_luma_minus8, bit_depth_chroma_minus8) -// from a High-Profile SPS NAL (ITU-T H.264 §7.3.2.1.1). Returns None if the -// SPS is too short/malformed. See docs/h264.md — parse_sps_high_profile_ext. +// Parse (chroma_format_idc, bit_depth_luma_minus8, bit_depth_chroma_minus8) from a High-Profile +// SPS NAL (ITU-T H.264 §7.3.2.1.1). Returns None if the SPS is too short/malformed. fn parse_sps_high_profile_ext(sps: &[u8]) -> Option<(u8, u8, u8)> { // Strip emulation-prevention bytes (00 00 03 xx -> 00 00 xx), skipping the // NAL header. Shares `unescape_ebsp` with the slice-header prefix reader — @@ -572,9 +569,8 @@ mod tests { } } - // Open-GOP resync fix: a non-IDR intra access unit must be flagged a - // keyframe so the B1 resync gate can disarm on a BD open-GOP tail. - // See docs/h264.md — open_gop_intra_access_unit_is_a_keyframe. + // Open-GOP resync fix: a non-IDR intra access unit must be flagged a keyframe so the B1 + // resync gate can disarm on a BD open-GOP tail. #[test] fn open_gop_intra_access_unit_is_a_keyframe() { // Annex B AU: one non-IDR coded slice (NAL type 1, nal_ref_idc 3 -> 0x61) @@ -658,9 +654,8 @@ mod tests { ); } - // The slice-header parse must remove emulation-prevention bytes first - // (ISO/IEC 14496-10 §7.3.1/§7.4.1) before decoding ue(v) fields. - // See docs/h264.md — slice_header_parse_removes_emulation_prevention_bytes. + // The slice-header parse must remove emulation-prevention bytes first (ISO/IEC 14496-10 + // §7.3.1/§7.4.1) before decoding ue(v) fields. #[test] fn slice_header_parse_removes_emulation_prevention_bytes() { // first_mb_in_slice=65535 needs 16 zero bits (ue(v)); combined with @@ -709,9 +704,8 @@ mod tests { ); } - // Regression: `parse_sps_high_profile_ext` used to re-derive the - // emulation-prevention rule with its own (disagreeing) window scanner. - // Pin the shared `unescape_ebsp` behaviour. See docs/h264.md. + // Regression: `parse_sps_high_profile_ext` used to re-derive the emulation-prevention rule + // with its own (disagreeing) window scanner. Pin the shared `unescape_ebsp` behaviour. #[test] fn unescape_ebsp_drops_escape_after_a_run_of_three_zeros() { assert_eq!( @@ -721,9 +715,8 @@ mod tests { ); } - // --- keyframe parameter-set re-assert: exact bytes + no whole-frame copy --- - // Must produce EXACTLY: active SPS, active PPS, then the AU's own NALs, - // each length-prefixed. See docs/h264.md — keyframe_param_reassert_emits_exact_bytes. + // --- keyframe parameter-set re-assert: exact bytes + no whole-frame copy --- Must produce + // EXACTLY: active SPS, active PPS, then the AU's own NALs, each length-prefixed. #[test] fn keyframe_param_reassert_emits_exact_bytes() { fn annexb(nal: &[u8]) -> Vec<u8> { @@ -767,9 +760,9 @@ mod tests { ); } - // MEASURED: the keyframe param-set re-assert must be spliced in place, - // not built as a fresh full-size buffer (avoids a whole-frame realloc+copy - // per keyframe). Mirrors HEVC's test of the same name. See docs/h264.md. + // MEASURED: the keyframe param-set re-assert must be spliced in place, not built as a fresh + // full-size buffer (avoids a whole-frame realloc+copy per keyframe). Mirrors HEVC's test of + // the same name. #[test] fn keyframe_param_reassert_does_not_reallocate_the_frame() { fn annexb(nal_header: u8, body: &[u8]) -> Vec<u8> { @@ -1870,9 +1863,8 @@ mod tests { ); } - // ISO 14496-15 §5.3.3.1.2 regression: profile_idc=244 (High 4:4:4 - // Predictive) also mandates the chroma/bit-depth extension; it was - // missing from HIGH_PROFILES. See docs/h264.md — avcc_profile_244. + // ISO 14496-15 §5.3.3.1.2 regression: profile_idc=244 (High 4:4:4 Predictive) also mandates + // the chroma/bit-depth extension; it was missing from HIGH_PROFILES. #[test] fn avcc_profile_244_appends_extension_bytes() { // profile_idc=244, chroma_format_idc=3 (4:4:4), depths both 4 (12-bit). diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index 03ab618f..7e17575f 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -38,8 +38,8 @@ const NAL_CRA_NUT: u8 = 21; /// are VCL, 32..=63 non-VCL. A coded slice carries a `slice_type`. const NAL_VCL_MAX: u8 = 31; -// `num_extra_slice_header_bits` from a HEVC PPS NAL (H.265 §7.3.2.3); `None` -// if the PPS is too short to parse. See docs/hevc.md — hevc_num_extra_slice_header_bits. +// `num_extra_slice_header_bits` from a HEVC PPS NAL (H.265 §7.3.2.3); `None` if the PPS is too +// short to parse. fn hevc_num_extra_slice_header_bits(pps_nal: &[u8]) -> Option<u32> { let mut br = BitReader::new(pps_nal.get(2..)?); br.read_ue()?; // pps_pic_parameter_set_id @@ -60,19 +60,17 @@ fn hevc_slice_coding_type(slice_type: u32) -> Option<CodingType> { } } -// The `pps_pic_parameter_set_id` (first ue(v)) of a PPS NAL — the key under -// which each PPS is stored so a slice's referenced PPS is resolved by its own -// id. `None` if the PPS is too short. See docs/hevc.md. +// The `pps_pic_parameter_set_id` (first ue(v)) of a PPS NAL — the key under which each PPS is +// stored so a slice's referenced PPS is resolved by its own id. `None` if the PPS is too short. fn hevc_pps_id(pps_nal: &[u8]) -> Option<u32> { BitReader::new(pps_nal.get(2..)?).read_ue() } -/// Measures the coding type from the FIRST coded slice of an access unit -/// (H.265 §7.3.6.1). `None` for a non-first slice or on truncation — never a -/// guess. `resolve_num_extra` maps the slice's OWN `slice_pic_parameter_set_id` -/// to that PPS's `num_extra_slice_header_bits`, so the `slice_type` bit offset is -/// taken from the PPS the slice references — not merely the last-active one. See -/// docs/hevc.md — hevc_first_slice_coding_type. +/// Measures the coding type from the FIRST coded slice of an access unit (H.265 §7.3.6.1). +/// `None` for a non-first slice or on truncation — never a guess. `resolve_num_extra` maps the +/// slice's OWN `slice_pic_parameter_set_id` to that PPS's `num_extra_slice_header_bits`, so the +/// `slice_type` bit offset is taken from the PPS the slice references — not merely the +/// last-active one. fn hevc_first_slice_coding_type( nal: &[u8], nal_type: u8, @@ -243,9 +241,8 @@ impl HevcParser { } } - // Combines mastering-display + content-light SEI into Hdr10Metadata, or - // `None` until BOTH are seen — never a half-populated (confidently-wrong) - // HDR10 record. See docs/hevc.md — HevcParser::hdr10. + // Combines mastering-display + content-light SEI into Hdr10Metadata, or `None` until BOTH + // are seen — never a half-populated (confidently-wrong) HDR10 record. fn hdr10(&self) -> Option<crate::mux::codec::Hdr10Metadata> { let m = self.sei_mastering?; let c = self.sei_content_light?; @@ -261,9 +258,8 @@ impl HevcParser { }) } - // Scans an SEI NAL for the two HDR10 payload types, capturing each the - // FIRST time it appears. See docs/hevc.md — HevcParser::scan_sei for the - // RBSP layout (H.265 D.2 ff-extension coding). + // Scans an SEI NAL for the two HDR10 payload types, capturing each the FIRST time it + // appears. fn scan_sei(&mut self, nal: &[u8]) { // Both HDR10 messages are sticky (first wins); once both are captured, // return before `strip_emulation_prevention` (alloc+copy) — an HDR10 UHD @@ -316,17 +312,15 @@ impl HevcParser { /// this point is rewritten CRA_NUT (21) → BLA_W_LP (16) so a linear decoder /// sets NoRaslOutput and discards the now-dangling RASL leading pictures. /// - /// MUST be called ONLY for connection_condition 0x05/0x06 — never for 0x01 - /// (seamless/first item) or within a single-clip title. See docs/hevc.md — - /// HevcParser::mark_clip_boundary for full rationale. + /// MUST be called ONLY for connection_condition 0x05/0x06 — never for 0x01 (seamless/first + /// item) or within a single-clip title. pub fn mark_clip_boundary(&mut self) { self.pending_clip_boundary = true; } } -// Handles a VPS/SPS/PPS NAL: strip it (decoder already has it) or emit it -// in-band, tracking the active body. Decision MUST be against `cur`, not the -// codecPrivate copy `first` — see docs/hevc.md — handle_param_set. +// Handles a VPS/SPS/PPS NAL: strip it (decoder already has it) or emit it in-band, tracking the +// active body. Decision MUST be against `cur`, not the codecPrivate copy `first` fn handle_param_set( first: &mut Option<Vec<u8>>, cur: &mut Option<Vec<u8>>, @@ -357,9 +351,8 @@ fn handle_param_set( true } -// Appends the active parameter set `cur` to `prefix` so every keyframe is -// self-contained. Unconditional (not just on divergence from codecPrivate) -// so a streaming decoder self-heals; see docs/hevc.md — reassert_active. +// Appends the active parameter set `cur` to `prefix` so every keyframe is self-contained. +// Unconditional (not just on divergence from codecPrivate) so a streaming decoder self-heals. fn reassert_active(prefix: &mut Vec<u8>, cur: &Option<Vec<u8>>, emitted: bool) { if emitted { return; @@ -385,13 +378,12 @@ fn push_length_prefixed(out: &mut Vec<u8>, nal: &[u8]) { out.extend_from_slice(nal); } -/// Walk a length-prefixed NAL buffer (`[u32-BE len][body]` records) and confirm the -/// records EXACTLY tile it: every declared length fits, none is zero, and the last -/// body ends precisely at the buffer end with no trailing bytes. Our `frame_data` -/// is length-prefixed by construction, so this is a self-consistency guard — a -/// `false` means a framing desync that a downstream demuxer would report as -/// "Invalid NAL unit size (N>M)", and such an access unit must be dropped rather -/// than emitted. See docs/hevc.md — length-prefix self-check (issue #52). +/// Walk a length-prefixed NAL buffer (`[u32-BE len][body]` records) and confirm the records +/// EXACTLY tile it: every declared length fits, none is zero, and the last body ends precisely +/// at the buffer end with no trailing bytes. Our `frame_data` is length-prefixed by +/// construction, so this is a self-consistency guard — a `false` means a framing desync that a +/// downstream demuxer would report as "Invalid NAL unit size (N>M)", and such an access unit +/// must be dropped rather than emitted. fn length_prefix_tiles(data: &[u8]) -> bool { let mut pos = 0usize; while pos < data.len() { @@ -814,9 +806,8 @@ fn read_sei_ff_value(rbsp: &[u8], i: &mut usize) -> Option<u32> { } } -// Parses a Mastering Display Colour Volume SEI payload (H.265 D.2.28), 24 -// bytes big-endian. `None` if shorter (malformed → ignored, never partial). -// Full byte layout: docs/hevc.md — parse_mastering_display. +// Parse the 24-byte big-endian Mastering Display Colour Volume SEI (H.265 D.2.28). +// Return None for a truncated payload. fn parse_mastering_display(p: &[u8]) -> Option<MasteringDisplay> { if p.len() < 24 { return None; @@ -1026,8 +1017,8 @@ mod tests { out } - // Wraps SEI messages in a prefix-SEI NAL (type 39) after a start code, - // emulation-prevented like a real encoder. See docs/hevc.md — sei_nal. + // Wraps SEI messages in a prefix-SEI NAL (type 39) after a start code, emulation-prevented + // like a real encoder. fn sei_nal(messages: &[Vec<u8>]) -> Vec<u8> { let mut rbsp = Vec::new(); for m in messages { @@ -1095,9 +1086,8 @@ mod tests { assert_eq!(h.max_pic_average_light_level, maxfall); } - // The FIRST mastering-display / content-light SEI wins; later repeats (or - // a corrupt splice) must not overwrite it. See docs/hevc.md — - // hevc_hdr10_sei_keeps_the_first_value_and_ignores_later_repeats. + // The FIRST mastering-display / content-light SEI wins; later repeats (or a corrupt splice) + // must not overwrite it. #[test] fn hevc_hdr10_sei_keeps_the_first_value_and_ignores_later_repeats() { let pps = { @@ -1155,9 +1145,7 @@ mod tests { ); } - // MEASURED: `scan_sei` must stop copying the RBSP once both HDR10 - // messages are captured. See docs/hevc.md — - // scan_sei_stops_copying_once_both_hdr10_messages_are_captured. + // MEASURED: `scan_sei` must stop copying the RBSP once both HDR10 messages are captured. #[test] fn scan_sei_stops_copying_once_both_hdr10_messages_are_captured() { let pps = { @@ -1344,9 +1332,8 @@ mod tests { assert_eq!(h.max_display_mastering_luminance, 10_000_000); } - // Every earlier fixture used num_extra_slice_header_bits == 0, so a - // parser that ignored the field entirely agreed with all of them. See - // docs/hevc.md — nonzero_num_extra_slice_header_bits_shifts_the_slice_type_offset. + // Every earlier fixture used num_extra_slice_header_bits == 0, so a parser that ignored the + // field entirely agreed with all of them. #[test] // The underscores here mark BITFIELD boundaries (e.g. 5-bit then 3-bit), // not thousands-style digit groups — regrouping them uniformly would @@ -1580,9 +1567,8 @@ mod tests { ); } - // Regression (UHD banded corruption): a bare keyframe after a mid-title - // PPS redefinition must re-assert the active PPS in-band. See - // docs/hevc.md — reasserts_active_pps_at_bare_keyframe. + // Regression (UHD banded corruption): a bare keyframe after a mid-title PPS redefinition + // must re-assert the active PPS in-band. #[test] fn reasserts_active_pps_at_bare_keyframe() { fn nal(t: u8, body: &[u8]) -> Vec<u8> { @@ -1668,9 +1654,8 @@ mod tests { ); } - // MEASURED: the keyframe param-set re-assert must splice into the - // already-assembled access unit IN PLACE, never a fresh full-size buffer. - // See docs/hevc.md — keyframe_param_reassert_does_not_reallocate_the_frame. + // MEASURED: the keyframe param-set re-assert must splice into the already-assembled access + // unit IN PLACE, never a fresh full-size buffer. #[test] fn keyframe_param_reassert_does_not_reallocate_the_frame() { fn nal(t: u8, body: &[u8]) -> Vec<u8> { @@ -1713,9 +1698,8 @@ mod tests { ); } - // Regression (a UHD title, the real bug): switching PPS id 0 back to its - // codecPrivate body must still be emitted in-band. See docs/hevc.md — - // emits_switch_back_to_codecprivate_pps. + // Regression (a UHD title, the real bug): switching PPS id 0 back to its codecPrivate body + // must still be emitted in-band. #[test] fn emits_switch_back_to_codecprivate_pps() { fn nal(t: u8, body: &[u8]) -> Vec<u8> { @@ -2044,9 +2028,8 @@ mod tests { ); } - // Regression (UHD Dolby Vision title): must AUTO-DETECT a non-seamless - // clip boundary from a backward PES-PTS reset, no `mark_clip_boundary` - // call. See docs/hevc.md — cra_at_auto_detected_pts_backstep_rewritten_to_bla. + // Regression (UHD Dolby Vision title): must AUTO-DETECT a non-seamless clip boundary from a + // backward PES-PTS reset, no `mark_clip_boundary` call. #[test] fn cra_at_auto_detected_pts_backstep_rewritten_to_bla() { let mut parser = HevcParser::new(); @@ -2080,9 +2063,8 @@ mod tests { ); } - // Regression (rc.5.2 audit #1): a SINGLE clip crossing the 2^33 PTS wrap - // must not be mistaken for a non-seamless clip join. See docs/hevc.md — - // cra_after_33bit_pts_wrap_not_rewritten. + // Regression (rc.5.2 audit #1): a SINGLE clip crossing the 2^33 PTS wrap must not be + // mistaken for a non-seamless clip join. #[test] fn cra_after_33bit_pts_wrap_not_rewritten() { let mut parser = HevcParser::new(); @@ -2110,9 +2092,8 @@ mod tests { ); } - // The wrap-vs-backstep test above doesn't distinguish `-` from a - // hand-flipped `+`; this uses PTS ~3e9 where they diverge. See - // docs/hevc.md — cra_splice_detected_at_large_pts_magnitude_not_masked_by_wrap_logic. + // The wrap-vs-backstep test above doesn't distinguish `-` from a hand-flipped `+`; this + // uses PTS ~3e9 where they diverge. #[test] fn cra_splice_detected_at_large_pts_magnitude_not_masked_by_wrap_logic() { let mut parser = HevcParser::new(); @@ -2146,8 +2127,7 @@ mod tests { ); } - // Non-CRA NALs are never rewritten even when a boundary IS marked. See - // docs/hevc.md — non_cra_nals_never_rewritten_at_boundary. + // Non-CRA NALs are never rewritten even when a boundary IS marked. #[test] fn non_cra_nals_never_rewritten_at_boundary() { // IDR boundary: marker set, but the first IRAP is an IDR → no rewrite, @@ -2192,9 +2172,8 @@ mod tests { ); } - // A stream with NO boundary marker is BYTE-IDENTICAL to pre-feature - // behaviour (UHD-safety guarantee). See docs/hevc.md — - // no_boundary_marker_is_byte_identical. + // A stream with NO boundary marker is BYTE-IDENTICAL to pre-feature behaviour (UHD-safety + // guarantee). #[test] fn no_boundary_marker_is_byte_identical() { let build = || { @@ -2245,9 +2224,8 @@ mod tests { ); } - // A SEAMLESS boundary is expressed by NOT calling `mark_clip_boundary`, - // so a CRA across a seamless join stays unchanged. See docs/hevc.md — - // seamless_boundary_no_rewrite. + // A SEAMLESS boundary is expressed by NOT calling `mark_clip_boundary`, so a CRA across a + // seamless join stays unchanged. #[test] fn seamless_boundary_no_rewrite() { // Simulate two clips joined seamlessly: the caller does NOT mark, so the @@ -2360,8 +2338,8 @@ mod tests { // --- parameter-set redefinition (mid-title redefinition bug) --- - // A parameter set REDEFINED mid-stream must be emitted INLINE so the - // decoder re-activates it. See docs/hevc.md — redefined_pps_emitted_inline. + // A parameter set REDEFINED mid-stream must be emitted INLINE so the decoder re-activates + // it. #[test] fn redefined_pps_emitted_inline() { let mut parser = HevcParser::new(); @@ -3012,9 +2990,8 @@ mod tests { assert_eq!(cp[18], 0xF8 | 4, "bit_depth_chroma_minus8 = 4"); } - // Builds a stored SPS NAL with sub-layers + a conformance window, so the - // parser must skip both before reaching bit depths. See docs/hevc.md — - // make_sps_full. + // Builds a stored SPS NAL with sub-layers + a conformance window, so the parser must skip + // both before reaching bit depths. fn make_sps_full( chroma_idc: u32, bd_luma_m8: u32, @@ -3321,9 +3298,8 @@ mod tests { ); } - // Every other fixture's VPS/SPS/PPS is under 256 bytes, so a `>>` -> `<<` - // mutation in the 16-bit array-length write is unobservable there. Uses - // 300+ byte NALs to catch it. See docs/hevc.md — hvcc_array_length_round_trips_above_256_bytes. + // Every other fixture's VPS/SPS/PPS is under 256 bytes, so a `>>` -> `<<` mutation in the + // 16-bit array-length write is unobservable there. Uses 300+ byte NALs to catch it. #[test] fn hvcc_array_length_round_trips_above_256_bytes() { let mut parser = HevcParser::new(); @@ -3369,9 +3345,8 @@ mod tests { ); } - // `with_ps_reorder(true)` must INSTALL the reorderer, and `flush()` must - // drain its real buffered frames at EOF. See docs/hevc.md — - // hevc_ps_reorder_is_installed_and_flush_drains_its_real_frames. + // `with_ps_reorder(true)` must INSTALL the reorderer, and `flush()` must drain its real + // buffered frames at EOF. #[test] fn hevc_ps_reorder_is_installed_and_flush_drains_its_real_frames() { const NAL_IDR_W_RADL: u8 = 19; // IRAP → keyframe / GOP anchor diff --git a/src/mux/codec/lpcm.rs b/src/mux/codec/lpcm.rs index 30b583be..8e28f340 100644 --- a/src/mux/codec/lpcm.rs +++ b/src/mux/codec/lpcm.rs @@ -4,8 +4,7 @@ //! DVD-PS LPCM payloads already had their private sub-header removed by //! `PsDemuxer`, so nothing further is stripped here. See `strip_header`. //! -//! Both origins map to MKV codec ID "A_PCM/INT/BIG" (big-endian); all -//! frames are keyframes. See docs/lpcm.md for wire-format details. +//! Both origins map to MKV codec ID "A_PCM/INT/BIG" (big-endian); all frames are keyframes. use super::{CodecParser, Frame, PesPacket, pts_to_ns}; diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index 8802086a..3a8dea3a 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -61,14 +61,11 @@ pub struct Frame { pub pts_ns: i64, /// Whether this is a keyframe (used for cue points). pub keyframe: bool, - /// This frame is the FIRST coded picture after a concealed/lost gap: its - /// data begins after packets the demuxer never received (an undecryptable - /// unit concealed as NULL-TS upstream, or a continuity break in a damaged - /// source). Inter-coded video frames carrying this flag reference data - /// that is gone, so the consumer's `ResyncGate` arms here and drops - /// forward to the next keyframe. Default `false`; only ever set on the - /// degraded/conceal path. See docs/codec-mod.md for why this is carried - /// per-frame rather than per-PES. + /// This frame is the FIRST coded picture after a concealed/lost gap: its data begins after + /// packets the demuxer never received (an undecryptable unit concealed as NULL-TS upstream, + /// or a continuity break in a damaged source). Inter-coded video frames carrying this flag + /// reference data that is gone, so the consumer's `ResyncGate` arms here and drops forward + /// to the next keyframe. Default `false`; only ever set on the degraded/conceal path. pub discontinuity: bool, /// Frame data (elementary stream bytes). pub data: Vec<u8>, @@ -168,8 +165,6 @@ impl CodecParser for PassthroughParser { } } -// See docs/codec-mod.md for the drop-on-undecodable policy across codecs -// ("clean muxes always") that motivates each parser's error handling. /// Create the appropriate parser for a codec, with optional codec private /// data. /// @@ -269,9 +264,8 @@ mod tests { } } - // codec_private() feeds MKV CodecPrivate (RFC 9559 §5.1.4.1.24): Some vs - // None are NOT interchangeable. See docs/codec-mod.md for why the gating - // parsers must answer "absent", never a fabricated Some. + // codec_private() feeds MKV CodecPrivate (RFC 9559 §5.1.4.1.24): Some vs None are NOT + // interchangeable. #[test] fn parsers_that_derive_no_config_report_absent_never_an_empty_codec_private() { // Codecs whose parsers do no configuration extraction, paired with a @@ -329,13 +323,11 @@ mod tests { #[cfg(test)] mod provenance_guard { - //! Every emitted frame must carry the source byte offset of the packet it - //! came from. That invariant held only for video for as long as it - //! existed: several audio/subtitle parsers built frames with `source: - //! None`, so a multi-clip title fell back to inferring the clip from - //! timestamps — which made branched titles run minutes long. This is a - //! brace-balanced scan of the tree, as a test. See docs/codec-mod.md for - //! the affected module list and how the gap was found. + //! Every emitted frame must carry the source byte offset of the packet it came from. That + //! invariant held only for video for as long as it existed: several audio/subtitle parsers + //! built frames with `source: None`, so a multi-clip title fell back to inferring the clip + //! from timestamps — which made branched titles run minutes long. This is a brace-balanced + //! scan of the tree, as a test. /// Every parser module's source, checked for a `Frame` built without a /// source. Paired with `every_codec_module_is_covered` below, which fails @@ -367,9 +359,8 @@ mod provenance_guard { "startcode", ]; - // Strip line comments before scanning: a comment merely mentioning - // `source: None` isn't a construction. See docs/codec-mod.md for why - // this and `frame_literals` need brace-balanced parsing, not regex. + // Strip line comments before scanning: a comment merely mentioning `source: None` isn't a + // construction. fn code_only(src: &str) -> String { // The guard itself constructs no Frame; it only talks about them, in // prose and in string literals. Scanning its own body matches both. diff --git a/src/mux/codec/mpeg2.rs b/src/mux/codec/mpeg2.rs index 81e6dc38..e0bfa1d7 100644 --- a/src/mux/codec/mpeg2.rs +++ b/src/mux/codec/mpeg2.rs @@ -6,8 +6,6 @@ //! with only the first carrying a PTS, so this parser buffers ES bytes across //! PES packets and emits exactly one Frame per coded picture (never one per //! PES, which would write truncated fragments). -//! -//! See docs/mpeg2.md for the full access-unit model and start-code reference. use super::coding::{CodingType, Mpeg2Coding, PictureInfo}; use super::startcode::find_start_code; @@ -40,8 +38,8 @@ const MAX_AU_BUFFER: usize = 8 * 1024 * 1024; /// ever arrives within the cap, buffered frames are released on a 0 base. const MAX_PENDING_FRAMES: usize = 600; -// Byte cap on frames held awaiting the first PES PTS anchor; mirrors the -// AC-3/DTS/PGS byte caps. See docs/mpeg2.md — `MAX_PENDING_BYTES`. +// Byte cap on frames held awaiting the first PES PTS anchor; mirrors the AC-3/DTS/PGS byte +// caps. const MAX_PENDING_BYTES: usize = 8 * 1024 * 1024; /// Frame rate table (index from sequence header frame_rate_code). @@ -160,7 +158,6 @@ impl Mpeg2Parser { } // Decode + buffer one reassembled AU into the current GOP. - // See docs/mpeg2.md — `Mpeg2Parser::process_au`. fn process_au(&mut self, au: crate::mux::au_assembly::AssembledAu, out: &mut Vec<Frame>) { let data = au.data; // An access unit must contain a coded picture; a fragment that assembled @@ -247,7 +244,6 @@ impl Mpeg2Parser { } // Emit the buffered GOP in decode order with display-order PTS/duration. - // See docs/mpeg2.md — `Mpeg2Parser::flush_gop`. fn flush_gop(&mut self, out: &mut Vec<Frame>) { let n = self.gop_buf.len(); if n == 0 { @@ -331,7 +327,6 @@ impl CodecParser for Mpeg2Parser { } // Extract the sequence header (+ B5 extensions) as MKV codecPrivate extradata. -// See docs/mpeg2.md — `extract_seq_header`. fn extract_seq_header(au: &[u8]) -> Option<Vec<u8>> { let b3 = find_code(au, 0, SEQ_HEADER_CODE)?; let mut end = au.len(); @@ -404,7 +399,6 @@ fn parse_aspect_ratio(hdr: &[u8]) -> Option<(u8, u8)> { } // Extract picture-coding-extension field/pulldown flags for `PictureInfo`. -// See docs/mpeg2.md — `picture_coding_flags`. fn picture_coding_flags(au: &[u8]) -> (bool, bool, bool, bool) { let mut search = 0; while let Some(q) = find_code(au, search, SEQ_EXT_CODE) { @@ -439,7 +433,6 @@ fn coding_type_from_raw(raw: u8) -> CodingType { } // Number of field-display periods a coded picture occupies (2:3 pulldown). -// See docs/mpeg2.md — `picture_nb_fields`. fn picture_nb_fields(au: &[u8], progressive_sequence: bool) -> u8 { let mut search = 0; while let Some(q) = find_code(au, search, SEQ_EXT_CODE) { @@ -476,7 +469,6 @@ fn picture_nb_fields(au: &[u8], progressive_sequence: bool) -> u8 { } // Read `progressive_sequence` from the sequence extension; false when absent. -// See docs/mpeg2.md — `parse_progressive_sequence`. fn parse_progressive_sequence(hdr: &[u8]) -> bool { let mut search = 0; while let Some(q) = find_code(hdr, search, SEQ_EXT_CODE) { @@ -838,8 +830,8 @@ mod tests { assert!(!frames[1].keyframe); } - // B1 hole-2 regression: the concealed gap must land on the post-gap picture, - // not the previous one. See docs/mpeg2.md — this test's name. + // B1 hole-2 regression: the concealed gap must land on the post-gap picture, not the + // previous one. #[test] fn discontinuity_offset_mark_stamps_post_gap_picture_not_previous() { let mut parser = Mpeg2Parser::new(); diff --git a/src/mux/codec/mpegaudio.rs b/src/mux/codec/mpegaudio.rs index 55a2d34a..a0998311 100644 --- a/src/mux/codec/mpegaudio.rs +++ b/src/mux/codec/mpegaudio.rs @@ -1,6 +1,4 @@ //! MPEG audio framing and validation. -//! -//! See docs/mpegaudio.md for framing, timestamp and validation rules. use super::audio_frames::{AudioFrames, Header}; #[cfg(test)] @@ -287,9 +285,8 @@ mod tests { ); } - // Complete frames have already been emitted; EOF must not turn a trailing - // invalid header or partial frame into another access unit. - // See docs/mpegaudio.md — flush-no-phantom-frame rationale. + // Complete frames have already been emitted; EOF must not turn a trailing invalid header or + // partial frame into another access unit. #[test] fn flush_adds_no_phantom_frame_after_the_last_real_packet() { let mut p = MpegAudioParser::new(); diff --git a/src/mux/codec/pesbuf.rs b/src/mux/codec/pesbuf.rs index 904909b7..656adddb 100644 --- a/src/mux/codec/pesbuf.rs +++ b/src/mux/codec/pesbuf.rs @@ -1,18 +1,16 @@ //! One accumulation buffer for parsers that assemble access units across PES //! packets. //! -//! The buffer owns the bytes and the marks together, so a unit's timestamp -//! and source offset always come from the PES that carried its first byte, -//! never taken from two different packets. See docs/pesbuf.md for the full -//! rationale. +//! The buffer owns the bytes and the marks together, so a unit's timestamp and source offset +//! always come from the PES that carried its first byte, never taken from two different +//! packets. use super::PesPacket; use super::pts_to_ns; use crate::pes::SourcePos; -// What a PES contributes to the bytes it carried, returned as a unit so a -// caller cannot mix fields from different packets. Timestamps are raw, as -// the packet had them; see docs/pesbuf.md for how codecs derive from them. +// What a PES contributes to the bytes it carried, returned as a unit so a caller cannot mix +// fields from different packets. Timestamps are raw, as the packet had them. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub(crate) struct PesFacts { /// Presentation time in NANOSECONDS, already derived (see `of`). Stored @@ -29,8 +27,8 @@ pub(crate) struct PesFacts { impl PesFacts { // The facts a PES packet carries, read straight off it. Every parser reads - // timestamp/source/discontinuity from PesFacts, never PesPacket directly, - // so they can't be mixed. See docs/pesbuf.md; PesBuf::front gives the same. + // timestamp/source/discontinuity from PesFacts, never PesPacket directly, so they can't be + // mixed. pub(crate) fn of(pes: &PesPacket) -> Self { Self { pts_ns: pes.pts.or(pes.dts).map(pts_to_ns), @@ -49,9 +47,8 @@ impl PesFacts { } } - // This unit's presentation time in nanoseconds — the ONE derivation. PTS and - // DTS differ for reordering (B-frame) streams, so video derives display - // order itself; see docs/pesbuf.md for audio/subtitle fallback rationale. + // This unit's presentation time in nanoseconds — the ONE derivation. PTS and DTS differ for + // reordering (B-frame) streams, so video derives display order itself. pub(crate) fn presentation_ns(&self) -> Option<i64> { self.pts_ns } @@ -165,9 +162,8 @@ impl PesBuf { self.buf.is_empty() } - // Consume `n` bytes from the front, rebasing marks onto the new front. The - // mark covering the new byte at offset 0 is retained even though its packet - // started earlier; see docs/pesbuf.md for why dropping it misattributes. + // Consume `n` bytes from the front, rebasing marks onto the new front. The mark covering + // the new byte at offset 0 is retained even though its packet started earlier. pub(crate) fn drain(&mut self, n: usize) { let n = n.min(self.buf.len()); if n == 0 { diff --git a/src/mux/codec/pgs.rs b/src/mux/codec/pgs.rs index a121ced6..640bddd9 100644 --- a/src/mux/codec/pgs.rs +++ b/src/mux/codec/pgs.rs @@ -8,7 +8,6 @@ //! later "empty" PCS (== 0) clears it. Preserve BOTH display sets: some //! decoders rely on the empty PCS + END even when Matroska carries a duration. //! Also set the visible block's `BlockDuration` to (clear_pts - display_pts). -// See docs/pgs.md for decoder and remux interoperability. use super::{CodecParser, Frame, PesPacket, pts_to_ns}; @@ -41,9 +40,6 @@ const PCS_FORCED_ON_FLAG: u8 = 0x40; /// begins with the display PCS (segment type 0x16), so the flag is read /// directly from it. Returns `None` when the block is not a display PCS with /// a composition object (clear PCS, non-PCS segment, or truncated header). -/// -/// See `docs/pgs.md` for why the mux uses this for forced-narrative-track -/// detection. pub fn display_set_is_forced(frame_data: &[u8]) -> Option<bool> { if frame_data.first() != Some(&SEGMENT_PCS) { return None; @@ -117,14 +113,12 @@ pub const DEMOTE_MIN_DISPLAY_SETS: u32 = 8; /// is the forced-narrative track its label claims and must keep that label. pub const DEMOTE_MIN_DISPLAY_SHARE_DIVISOR: u32 = 4; -/// Whether content evidence is strong enough to CONTRADICT a vendor label that -/// says a track is forced — i.e. to demote 1 → 0. Promotion needs no such -/// gate; demotion requires, in order: something observed at all; the flag IN -/// USE (`disc_uses_forced_flag`, or on this very track); and the track's -/// SHAPE ([`DEMOTE_MIN_DISPLAY_SETS`], [`DEMOTE_MIN_DISPLAY_SHARE_DIVISOR`]) -/// matching a full dialogue track rather than a forced-narrative one. -/// `busiest_displays` is the largest `displays` over every subtitle track -/// judged together. See `docs/pgs.md` for the full rationale and shapes. +/// Whether content evidence is strong enough to CONTRADICT a vendor label that says a track is +/// forced — i.e. to demote 1 → 0. Promotion needs no such gate; demotion requires, in order: +/// something observed at all; the flag IN USE (`disc_uses_forced_flag`, or on this very track); +/// and the track's SHAPE ([`DEMOTE_MIN_DISPLAY_SETS`], [`DEMOTE_MIN_DISPLAY_SHARE_DIVISOR`]) +/// matching a full dialogue track rather than a forced-narrative one. `busiest_displays` is the +/// largest `displays` over every subtitle track judged together. pub fn demotable(facts: ForcedFacts, disc_uses_forced_flag: bool, busiest_displays: u32) -> bool { if facts.displays == 0 { return false; @@ -414,9 +408,9 @@ mod tests { assert_eq!(display_set_is_forced(&[]), None); } - // `observed()` distinguishes "unknown" (leave the vendor flag alone) from a - // settled verdict, so an unread/undecrypted track can't overwrite a correct - // vendor "forced" flag with "not forced". See docs/pgs.md for detail. + // `observed()` distinguishes "unknown" (leave the vendor flag alone) from a settled + // verdict, so an unread/undecrypted track can't overwrite a correct vendor "forced" flag + // with "not forced". #[test] fn observed_stays_false_until_a_real_display_set_is_seen() { let mut t = ForcedTracker::new(); @@ -854,8 +848,8 @@ mod tests { } } - // A track that mixes forced and non-forced sets needs no sibling - // corroboration: the flag is in use ON THIS TRACK. See docs/pgs.md. + // A track that mixes forced and non-forced sets needs no sibling corroboration: the flag is + // in use ON THIS TRACK. #[test] fn a_mixed_track_corroborates_the_flag_itself() { assert!(demotable(facts(108, 2), false, 137)); @@ -919,8 +913,8 @@ mod tests { assert_eq!(t.facts().forced_displays, u32::MAX); } - // A lone non-PCS segment with a PTS is emitted straight through rather - // than accumulated, and must still carry provenance. See docs/pgs.md. + // A lone non-PCS segment with a PTS is emitted straight through rather than accumulated, + // and must still carry provenance. #[test] fn a_lone_segment_emitted_directly_still_carries_provenance() { let mut parser = PgsParser::new(); diff --git a/src/mux/codec/reorder.rs b/src/mux/codec/reorder.rs index 7e0dc2a2..fd9f06c9 100644 --- a/src/mux/codec/reorder.rs +++ b/src/mux/codec/reorder.rs @@ -1,9 +1,8 @@ //! Display-order PTS reconstruction for sparse-PTS program-stream video. //! -//! MPEG program streams (DVD VOB, HD-DVD EVO) timestamp video at GOP -//! granularity, so H.264/HEVC/VC-1 non-anchor frames collapse to the same -//! block timestamp and a decoder reports non-monotonic DTS. See -//! docs/reorder.md for the full background and the reordering rule. +//! MPEG program streams (DVD VOB, HD-DVD EVO) timestamp video at GOP granularity, so +//! H.264/HEVC/VC-1 non-anchor frames collapse to the same block timestamp and a decoder reports +//! non-monotonic DTS. //! //! [`SparsePtsReorder`](crate::mux::codec::reorder::SparsePtsReorder) //! reconstructs a display-order PTS per frame from picture type (I/P/B) and @@ -12,17 +11,16 @@ use super::Frame; use super::coding::CodingType; -// Fallback per-frame duration (ns) when anchor spacing can't calibrate one: -// 24000/1001 fps film, the dominant HD-DVD cadence. See docs/reorder.md. +// Fallback per-frame duration (ns) when anchor spacing can't calibrate one: 24000/1001 fps +// film, the dominant HD-DVD cadence. const FALLBACK_FRAME_DUR_NS: i64 = 1_001_000_000 / 24; -// Force-complete the current GOP at this many buffered pictures even without -// a keyframe, so a stream that never signals one can't buffer unbounded RAM. -// See docs/reorder.md. +// Force-complete the current GOP at this many buffered pictures even without a keyframe, so a +// stream that never signals one can't buffer unbounded RAM. const MAX_GOP_FRAMES: usize = 600; -// Byte cap on the buffered GOP, complementing MAX_GOP_FRAMES, so few-but-huge -// access units can't over-allocate either. See docs/reorder.md. +// Byte cap on the buffered GOP, complementing MAX_GOP_FRAMES, so few-but-huge access units +// can't over-allocate either. const MAX_GOP_BYTES: usize = 64 * 1024 * 1024; /// One buffered coded picture awaiting its GOP's completion. @@ -187,8 +185,8 @@ impl SparsePtsReorder { } } -// Display index (0-based, decode order in -> decode order out) via the -// classic single-anchor-delay reorder. See docs/reorder.md. +// Display index (0-based, decode order in -> decode order out) via the classic +// single-anchor-delay reorder. fn display_indices(types: impl Iterator<Item = CodingType>) -> Vec<i64> { let types: Vec<CodingType> = types.collect(); let mut disp = vec![0i64; types.len()]; diff --git a/src/mux/codec/startcode.rs b/src/mux/codec/startcode.rs index 1abdc285..a07e4a2c 100644 --- a/src/mux/codec/startcode.rs +++ b/src/mux/codec/startcode.rs @@ -38,9 +38,8 @@ pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> { None } -// MSB-first bit reader over an RBSP, for the leading fields of a coded slice -// header. Does NOT de-emulate `00 00 03`; safe only for the leading fields. -// See docs/startcode.md — BitReader emulation-prevention rationale. +// MSB-first bit reader over an RBSP, for the leading fields of a coded slice header. Does NOT +// de-emulate `00 00 03`; safe only for the leading fields. pub(crate) struct BitReader<'a> { data: &'a [u8], bit: usize, diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index edcb16a6..f9fe277d 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -21,8 +21,8 @@ fn is_mlp_major_sync(w: u32) -> bool { (w & 0xFFFF_FFFE) == 0xF872_6FBA } -// Is `w` specifically the TrueHD major sync (stream type 0xBA)? Must be -// exact, not the 0xBA/0xBB mask — see docs/truehd.md for why. +// Is `w` specifically the TrueHD major sync (stream type 0xBA)? Must be exact, not the +// 0xBA/0xBB mask. fn is_truehd_major_sync(w: u32) -> bool { w == 0xF872_6FBA } @@ -95,8 +95,7 @@ impl TrueHdParser { self.tally.dropped_duration_ns() } - // Decide whether an AU is corrupt, updating `num_substreams` from a - // valid major sync. See docs/truehd.md#au_check-corruption-decision. + // Decide whether an AU is corrupt, updating `num_substreams` from a valid major sync. fn au_check(&mut self, au: &[u8], is_major_sync: bool) -> AuCheck { let mut header_size = 4; let mut format_info = None; @@ -144,8 +143,7 @@ impl TrueHdParser { } } - // Size (bytes) of the AC-3 frame at the buffer head: Unmappable/NeedMore/ - // Frame(n). See docs/truehd.md#ac3_frame_at_head-ac-3-size-lookup. + // Size (bytes) of the AC-3 frame at the buffer head: Unmappable/NeedMore/ Frame(n). fn ac3_frame_at_head(&self) -> Ac3Size { if self.acc.len() < 6 { return Ac3Size::NeedMore; @@ -162,8 +160,7 @@ impl TrueHdParser { } } -// Secondary validation: is the AC-3 frame's computed end a plausible -// boundary? See docs/truehd.md#ac3_boundary_corroborated. +// Secondary validation: is the AC-3 frame's computed end a plausible boundary? fn ac3_boundary_corroborated(buf: &[u8], frame_bytes: usize) -> bool { if frame_bytes >= buf.len() { // The AC-3 frame is fully buffered and ends the data — consistent. @@ -213,8 +210,7 @@ enum Ac3Size { // --- MLP/TrueHD access-unit integrity (per the MLP/TrueHD bitstream spec) --- -// Major-sync header size: base 28 + `2 + extensions*2` when the extension -// flag is set. See docs/truehd.md#mlp_major_sync_header_size. +// Major-sync header size: base 28 + `2 + extensions*2` when the extension flag is set. fn mlp_major_sync_header_size(ms: &[u8]) -> Option<usize> { if ms.len() < 28 { return None; @@ -229,8 +225,7 @@ fn mlp_major_sync_header_size(ms: &[u8]) -> Option<usize> { Some(size) } -// Validate the major-sync header checksum (CRC-16, poly 0x002D, byte- -// reversed vs standard). See docs/truehd.md#mlp_major_sync_crc_ok. +// Validate the major-sync header checksum (CRC-16, poly 0x002D, byte- reversed vs standard). fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool { if mshdr < 4 || ms.len() < mshdr { return false; @@ -543,12 +538,11 @@ pub fn truehd_channels_from_stream(data: &[u8]) -> Option<u8> { /// Real sample rate (Hz) from a TrueHD major-sync `format_info` word. /// -/// The 4-bit `ratebits` nibble sits in `format_info` bits 31..28, the same -/// word `truehd_channels` reads for the channel masks. This is a **strict -/// whitelist** of the six rates that occur on real BD/UHD TrueHD; every -/// other code returns `None` so a malformed field can never produce a wrong -/// `SamplingFrequency` — the caller falls back to its container-derived -/// rate. See docs/truehd.md for the full rate formula and rationale. +/// The 4-bit `ratebits` nibble sits in `format_info` bits 31..28, the same word +/// `truehd_channels` reads for the channel masks. This is a **strict whitelist** of the six +/// rates that occur on real BD/UHD TrueHD; every other code returns `None` so a malformed field +/// can never produce a wrong `SamplingFrequency` — the caller falls back to its +/// container-derived rate. pub fn truehd_sample_rate_hz(format_info: u32) -> Option<u32> { match (format_info >> 28) & 0xF { 0x0 => Some(48000), @@ -644,8 +638,7 @@ mod tests { } } - // Non-degenerate substream count/directory size test — see - // docs/truehd.md#mlp_substr_header_size-test-rationale. + // Non-degenerate substream count/directory size test. #[test] fn mlp_num_substreams_is_the_top_nibble_of_major_sync_byte_16() { // Byte 16 of the major sync: top nibble is num_substreams, bottom nibble @@ -711,8 +704,7 @@ mod tests { data } - // Make a synthetic major-sync AU pass the decodability gate (CRC + - // parity). See docs/truehd.md#finalize_major_sync-test-helper. + // Make a synthetic major-sync AU pass the decodability gate (CRC + parity). fn finalize_major_sync(au: &mut [u8]) { const MSHDR: usize = 28; // no extension (byte 25 clear) // num_substreams = 1 → major-sync byte 16 (AU[20]) top nibble. @@ -872,8 +864,7 @@ mod tests { // `corrupt_major_sync_drops_forward_to_next_valid`). } - // Independent bitwise CRC-16 oracle (poly 0x002D), not tautological - // with `crc16_mlp`. See docs/truehd.md#ref_crc16_2d-test-oracle. + // Independent bitwise CRC-16 oracle (poly 0x002D), not tautological with `crc16_mlp`. fn ref_crc16_2d(data: &[u8]) -> u16 { let mut crc: u16 = 0; for &b in data { diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs index ac690831..aa242db8 100644 --- a/src/mux/codec/vc1.rs +++ b/src/mux/codec/vc1.rs @@ -14,9 +14,8 @@ const SC_SEQUENCE_HEADER: u8 = 0x0F; const SC_ENTRY_POINT: u8 = 0x0E; const SC_FRAME: u8 = 0x0D; -// Read the advanced-profile sequence header's `INTERLACE` flag (SMPTE 421M -// §6.1.1), bit 41 after the start code. `None` for simple/main profile. -// See docs/vc1.md — parse_vc1_interlace bit layout. +// Read the advanced-profile sequence header's `INTERLACE` flag (SMPTE 421M §6.1.1), bit 41 +// after the start code. `None` for simple/main profile. fn parse_vc1_interlace(sh: &[u8]) -> Option<bool> { if sh.len() < 8 || (sh[4] >> 6) & 0x03 != 3 { return None; // need the start code + advanced profile (PROFILE == 3) @@ -46,9 +45,8 @@ fn parse_vc1_interlace(sh: &[u8]) -> Option<bool> { Some((bits >> 6) & 1 == 1) } -// Decode the advanced-profile **progressive** picture PTYPE VLC (SMPTE 421M -// §7.1.1.4). Only valid when the sequence is progressive — interlaced uses -// an FCM/FPTYPE code instead. See docs/vc1.md — vc1_progressive_ptype. +// Decode the advanced-profile **progressive** picture PTYPE VLC (SMPTE 421M §7.1.1.4). Only +// valid when the sequence is progressive — interlaced uses an FCM/FPTYPE code instead. fn vc1_progressive_ptype(br: &mut BitReader) -> Option<CodingType> { if br.read_bit()? == 0 { return Some(CodingType::P); // 0 @@ -67,9 +65,8 @@ fn vc1_progressive_ptype(br: &mut BitReader) -> Option<CodingType> { }) } -// Measure the coding type of an advanced-profile frame from its picture -// header. Decodes PTYPE only for a PROGRESSIVE sequence; declines (`None`) -// for interlaced/simple-main/unknown. See docs/vc1.md — vc1_frame_coding_type. +// Measure the coding type of an advanced-profile frame from its picture header. Decodes PTYPE +// only for a PROGRESSIVE sequence; declines (`None`) for interlaced/simple-main/unknown. fn vc1_frame_coding_type(frame_rbsp: &[u8], seq_header: Option<&[u8]>) -> Option<CodingType> { if parse_vc1_interlace(seq_header?)? { return None; // interlaced: FCM/FPTYPE not decoded here @@ -134,9 +131,8 @@ impl Vc1Parser { } } -// Handle a seq_header or entry_point start-code unit (Annex B raw bytes). -// Strips the first-seen (codecPrivate-seeding) and redundant copies; emits -// only a genuine change vs the active body. See docs/vc1.md — handle_header. +// Handle a seq_header or entry_point start-code unit (Annex B raw bytes). Strips the first-seen +// (codecPrivate-seeding) and redundant copies; emits only a genuine change vs the active body. fn handle_header( first: &mut Option<Vec<u8>>, cur: &mut Option<Vec<u8>>, @@ -358,9 +354,8 @@ impl CodecParser for Vc1Parser { } } -// Parse width and height from a VC-1 advanced profile sequence header -// (00 00 01 0F ...). Coded dimensions are 12-bit fields. See docs/vc1.md -// — parse_vc1_resolution bit layout. +// Parse width and height from a VC-1 advanced profile sequence header (00 00 01 0F...). Coded +// dimensions are 12-bit fields. fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> { // sh starts at the start code (00 00 01 0F ...) if sh.len() < 8 { @@ -750,9 +745,8 @@ mod tests { assert_eq!(frames[0].pts_ns, 2_000_000_000); } - // --- advanced-profile resolution parsing (bit-offset regression) --- - // Builds a seq header encoding width/height (PROFILE=3 + coded W/H - // fields). See docs/vc1.md — make_ap_seq_header bit layout. + // --- advanced-profile resolution parsing (bit-offset regression) --- Builds a seq header + // encoding width/height (PROFILE=3 + coded W/H fields). fn make_ap_seq_header(width: u32, height: u32) -> Vec<u8> { let coded_w = (width / 2) - 1; let coded_h = (height / 2) - 1; @@ -1023,9 +1017,8 @@ mod tests { assert_eq!(&extra[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]); } - // --- regression: mid-stream entry_point A→B→A revert emitted in-band --- - // Reverted A must still be emitted IN-BAND (decoder already on B needs - // the explicit revert). See docs/vc1.md — entry_point_revert_to_first. + // --- regression: mid-stream entry_point A→B→A revert emitted in-band --- Reverted A must + // still be emitted IN-BAND (decoder already on B needs the explicit revert). #[test] fn vc1_emits_entry_point_revert_to_first_value() { let sh = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB]; @@ -1125,9 +1118,8 @@ mod tests { ); } - // Regression: keyframe with seq_header UNCHANGED but entry_point REDEFINED - // must still assemble prefix in seq-then-entry order (SMPTE 421M). See - // docs/vc1.md — vc1_keyframe_prefix_order_seq_unchanged_entry_redefined. + // Regression: keyframe with seq_header UNCHANGED but entry_point REDEFINED must still + // assemble prefix in seq-then-entry order (SMPTE 421M). #[test] fn vc1_keyframe_prefix_order_seq_unchanged_entry_redefined() { let sh = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB, 0xCC]; diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 0d07ed31..e9864de0 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -2,11 +2,9 @@ //! stream file (per-codec ES, PGS `.sup`, VobSub `.idx`/`.sub`, LPCM raw PCM), //! plus a chapters file and per-audio-track delay metadata. //! -//! This is a write-only [`crate::pes::Stream`] that routes each frame's -//! payload to the file for `frame.track`, post-processing where the codec's -//! internal `Frame` form differs from the on-disk ES form (HEVC/H.264 -//! Annex-B, PGS `.sup`, VobSub `.idx`) — see docs/demux-sink.md for details. -//! Other codecs write `frame.data` verbatim; this sink is purely additive. +//! This is a write-only [`crate::pes::Stream`] that routes each frame's payload to the file for +//! `frame.track`, post-processing where the codec's internal `Frame` form differs from the +//! on-disk ES form (HEVC/H.264 Annex-B, PGS `.sup`, VobSub `.idx`). use crate::disc::{Chapter, Codec, DiscTitle, Stream as DiscStream}; use crate::mux::hevc::{ @@ -281,9 +279,8 @@ fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> { }) } -// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped, -// prefixing each segment with a 13-byte `PG` header (PTS/DTS). -// See docs/demux-sink.md — PgsSupWriter. +// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped, prefixing each +// segment with a 13-byte `PG` header (PTS/DTS). #[derive(Default)] struct PgsSupWriter { // Delay a duration-derived clear until the next frame, so an original @@ -349,9 +346,8 @@ impl PgsSupWriter { Ok(written) } - // Synthetic "clear" display set (empty PCS + END) re-emitted at - // `display_pts + duration`; without it every subtitle lingers to EOF. - // See docs/demux-sink.md — synthetic_clear_display_set. + // Synthetic "clear" display set (empty PCS + END) re-emitted at `display_pts + duration`; + // without it every subtitle lingers to EOF. fn synthetic_clear_display_set(width: u16, height: u16) -> Vec<u8> { // Empty PCS payload (HDMV PGS, BD-ROM Part 3): width(2) height(2) // frame_rate(1) composition_number(2) composition_state(1) @@ -622,9 +618,8 @@ fn xml_escape(s: &str) -> String { .replace('>', ">") } -// Replace path-hostile characters (control chars incl. NUL) in a filename -// component; disc-derived text (e.g. STN language codes) is unvalidated. -// See docs/demux-sink.md — sanitize. +// Replace path-hostile characters (control chars incl. NUL) in a filename component; +// disc-derived text (e.g. STN language codes) is unvalidated. fn sanitize(s: &str) -> String { s.chars() .map(|c| match c { @@ -1729,9 +1724,8 @@ mod tests { assert_eq!(a, out); } - // Regression: the epoch driver must follow `ref_video_track` (dynamic), - // not a hardcoded `frame.track == 0` — a PMT can list audio before video. - // See docs/demux-sink.md — epoch_driver_follows_ref_video_not_track_zero. + // Regression: the epoch driver must follow `ref_video_track` (dynamic), not a hardcoded + // `frame.track == 0` — a PMT can list audio before video. #[test] fn epoch_driver_follows_ref_video_not_track_zero() { let dir = tempdir(); @@ -1859,9 +1853,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - // A demux export whose seam plan drops every frame must FAIL, not write - // empty track files and report success. - // See docs/demux-sink.md — a_demux_export_the_seam_plan_emptied_fails. + // A demux export whose seam plan drops every frame must FAIL, not write empty track files + // and report success. #[test] fn a_demux_export_the_seam_plan_emptied_fails() { let dir = tempdir(); @@ -1912,11 +1905,10 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// A demux export where the seam plan drops MORE frames than it persists - /// must report SeamPlanDroppedMost — even though at least one frame did - /// persist (frames_mapped > 0). The denominator counts persisted frames - /// only, so a mostly-emptied export can no longer slip past as success. - /// See docs/demux-sink.md — a_demux_export_the_seam_plan_mostly_emptied_fails. + /// A demux export where the seam plan drops MORE frames than it persists must report + /// SeamPlanDroppedMost — even though at least one frame did persist (frames_mapped > 0). + /// The denominator counts persisted frames only, so a mostly-emptied export can no longer + /// slip past as success. #[test] fn a_demux_export_the_seam_plan_mostly_emptied_fails() { let dir = tempdir(); @@ -1978,13 +1970,12 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// A FILTERED export (`audio://`) must NOT trip the drop gates on drops that - /// belong to a track it never persists. The video track is dropped hard at - /// the clip join (2 frames, all outside the marks), while the persisted audio - /// track loses nothing. `dropped_total()` would fold the video drops (2) into - /// the numerator and, exceeding the persisted count (1), wrongly report - /// SeamPlanDroppedMost; `dropped_for(persisted_tracks)` sees 0 and passes. - /// See docs/demux-sink.md — a_filtered_export_ignores_drops_on_filtered_tracks. + /// A FILTERED export (`audio://`) must NOT trip the drop gates on drops that belong to a + /// track it never persists. The video track is dropped hard at the clip join (2 frames, all + /// outside the marks), while the persisted audio track loses nothing. `dropped_total()` + /// would fold the video drops (2) into the numerator and, exceeding the persisted count + /// (1), wrongly report SeamPlanDroppedMost; `dropped_for(persisted_tracks)` sees 0 and + /// passes. #[test] fn a_filtered_export_ignores_drops_on_non_persisted_tracks() { let dir = tempdir(); diff --git a/src/mux/demux_thread.rs b/src/mux/demux_thread.rs index 1c0df7da..e28ccc0d 100644 --- a/src/mux/demux_thread.rs +++ b/src/mux/demux_thread.rs @@ -6,8 +6,6 @@ //! [`DemuxThread::spawn_zero_copy`] returns a handle plus a //! `Receiver<DemuxBatch>`; dropping the handle closes the channel and //! `Drop::drop` joins the worker. -//! -//! See docs/demux-thread.md for rationale and lifecycle details. use crate::halt::Halt; use crossbeam_channel::{Receiver, Sender, bounded}; @@ -52,11 +50,10 @@ impl DemuxThread { /// filled buffers arrive via `prefetch_rx`, get fed, then are /// returned to `recycle_tx` for the producer to re-fill. /// - /// `producer_shell` is an opaque handle that outlives the demux - /// thread and joins the upstream producer on drop. Accepts a shell from + /// `producer_shell` is an opaque handle that outlives the demux thread and joins the + /// upstream producer on drop. Accepts a shell from /// [`crate::sector::PrefetchedSectorSource::into_channels`] or /// [`crate::io::byte_prefetcher::BytePrefetcher::into_channels`]. - /// See docs/demux-thread.md for the memcpy/alloc-elimination rationale. pub fn spawn_zero_copy<S: Send + 'static>( prefetch_rx: Receiver<std::io::Result<Vec<u8>>>, recycle_tx: Sender<Vec<u8>>, @@ -498,9 +495,8 @@ mod tests { assert!(matches!(batches[1], DemuxBatch::Eof)); } - // Regression: worker must detect consumer disconnect even when every batch - // is empty, else it spins through all remaining extents before exiting - // (join() blocking for minutes). See docs/demux-thread.md — empty-batch disconnect fix. + // Regression: worker must detect consumer disconnect even when every batch is empty, else + // it spins through all remaining extents before exiting (join() blocking for minutes). #[test] fn worker_exits_promptly_on_consumer_drop_during_empty_batches() { // Use an untracked PID so every batch the demuxer produces is empty. @@ -554,9 +550,8 @@ mod tests { ); } - // Regression: on spawn failure, channels must drop before producer_shell so - // the producer observes disconnection and join() doesn't hang. See - // docs/demux-thread.md — spawn-failure drop-order test rationale. + // Regression: on spawn failure, channels must drop before producer_shell so the producer + // observes disconnection and join() doesn't hang. #[test] fn channels_disconnected_before_producer_join_on_spawn_failure() { // The producer "thread" is simulated by holding prefetch_tx; verify it diff --git a/src/mux/disc.rs b/src/mux/disc.rs index e2d931f9..87726428 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -373,9 +373,8 @@ impl DiscStream { self.reader.set_keys(crate::decrypt::DecryptKeys::None); } - // Commit a successful read_sectors, zero-filling+accounting a short read - // under skip_errors (never a silent hole) instead of trusting the count. - // See docs/mux-disc.md — commit_read. + // Commit a successful read_sectors, zero-filling+accounting a short read under skip_errors + // (never a silent hole) instead of trusting the count. fn commit_read(&mut self, lba: u32, got: usize, bytes: usize) -> io::Result<()> { if got < bytes { if !self.skip_errors { @@ -1150,9 +1149,8 @@ mod tests { ); } - // A malformed disc can declare thousands of zero-sector extents; the old - // self-recursive skip overflowed the stack (fatal, unlike iterative EOF). - // See docs/mux-disc.md — a_long_run_of_empty_extents_does_not_recurse_per_extent. + // A malformed disc can declare thousands of zero-sector extents; the old self-recursive + // skip overflowed the stack (fatal, unlike iterative EOF). #[test] fn a_long_run_of_empty_extents_does_not_recurse_per_extent() { let title = DiscTitle { @@ -1737,9 +1735,8 @@ mod tests { ); } - // REGRESSION (round-4 audit): an ordinary MEDIUM ERROR bad sector must - // keep its identity crossing the prefetch channel, not become a fabricated - // transport failure. See docs/mux-disc.md — bad_sector_keeps_its_identity_across_the_prefetch_channel. + // REGRESSION (round-4 audit): an ordinary MEDIUM ERROR bad sector must keep its identity + // crossing the prefetch channel, not become a fabricated transport failure. #[test] fn bad_sector_keeps_its_identity_across_the_prefetch_channel() { const COUNT: u32 = 9; @@ -1782,9 +1779,8 @@ mod tests { ); } - // The prefetch producer dies permanently on its first read error; driving - // fill_extents to exhaustion after that must NOT look like a completed - // pass. See docs/mux-disc.md — dead_prefetch_producer_does_not_silently_zero_fill_the_title. + // The prefetch producer dies permanently on its first read error; driving fill_extents to + // exhaustion after that must NOT look like a completed pass. #[test] fn dead_prefetch_producer_does_not_silently_zero_fill_the_title() { const COUNT: u32 = 30; @@ -2138,9 +2134,8 @@ mod tests { ); } - // Frames the B1 resync gate discards must reach errors(), including - // after the gap resolves (ResyncGate::dropped zeroes on resync). See - // docs/mux-disc.md — errors_reports_frames_the_resync_gate_dropped_after_the_gap_resolves. + // Frames the B1 resync gate discards must reach errors(), including after the gap + // resolves (ResyncGate::dropped zeroes on resync). #[test] fn errors_reports_frames_the_resync_gate_dropped_after_the_gap_resolves() { let mut s = short_read_stream(true); diff --git a/src/mux/driver.rs b/src/mux/driver.rs index a2b5082b..a5739914 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -4,9 +4,7 @@ //! //! `mux_stream` DRIVES the existing pipeline via the same //! [`build_iso_pipeline`](crate::mux::resolve::build_iso_pipeline) and a -//! [`WRITE_PIPELINE_DEPTH`]-deep write [`Pipeline`]; it does not replace -//! either. See docs/mux-driver.md for the full rationale and the -//! gate-ordering bug fix (metadata sinks bypass the headers-resolved gate). +//! [`WRITE_PIPELINE_DEPTH`]-deep write [`Pipeline`]; it does not replace either. use std::path::Path; use std::sync::Arc; @@ -28,9 +26,8 @@ use super::resolve::{ }; use super::videomap::{Medium, SourceInfo}; -// The source medium a parsed input URL denotes (`docs/FVI_FORMAT.md` §6.2), -// used only for provenance. Non-legal-input URLs never reach a sink, so -// their arm is immaterial — falls to the `File` default. +// The source medium a parsed input URL denotes, used only for provenance. Non-legal-input URLs +// never reach a sink, so their arm is immaterial — falls to the `File` default. fn url_medium(parsed: &StreamUrl) -> Medium { match parsed { StreamUrl::Disc { .. } => Medium::Disc, @@ -41,9 +38,9 @@ fn url_medium(parsed: &StreamUrl) -> Medium { } } -// Effectively-unbounded per-frame send deadline for `send_deadline == None` -// (CLI interactive stdout/network): blocks on backpressure without timing -// out (halt is still checked every 250 ms). See docs/mux-driver.md. +// Effectively-unbounded per-frame send deadline for `send_deadline == None` (CLI interactive +// stdout/network): blocks on backpressure without timing out (halt is still checked every 250 +// ms). const NO_SEND_DEADLINE: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60); // Resolve `MuxOptions::send_deadline` into the concrete per-frame deadline: @@ -53,9 +50,8 @@ fn effective_send_deadline(send_deadline: Option<Duration>) -> Duration { send_deadline.unwrap_or(NO_SEND_DEADLINE) } -// Ceiling on bytes buffered while waiting for `headers_ready()`, so a -// damaged title whose `codec_private` never resolves fails fast instead -// of OOM-killing the process. See docs/mux-driver.md. +// Ceiling on bytes buffered while waiting for `headers_ready()`, so a damaged title whose +// `codec_private` never resolves fails fast instead of OOM-killing the process. const HEADER_BUFFER_CAP_BYTES: usize = 512 * 1024 * 1024; /// Where [`mux_stream`] reads its PES frames from. The driver owns the @@ -151,12 +147,11 @@ pub struct MuxOptions { pub selection: crate::StreamSelection, /// Per-frame write-pipeline send deadline. /// - /// - `Some(d)` — a hard `d` timeout: a sink that back-pressures a single - /// frame past `d` is treated as wedged and the mux returns - /// `completed = false`. autorip passes `Some(Duration::from_secs(60))`. - /// - `None` — no backpressure timeout: the send blocks as long as the - /// downstream is alive (only a `halt` interrupts it). The CLI's - /// interactive stdout / network sinks pass `None`. See docs/mux-driver.md. + /// - `Some(d)` — a hard `d` timeout: a sink that back-pressures a single frame past `d` + /// is treated as wedged and the mux returns `completed = false`. autorip passes + /// `Some(Duration::from_secs(60))`. - `None` — no backpressure timeout: the send blocks as + /// long as the downstream is alive (only a `halt` interrupts it). The CLI's interactive + /// stdout / network sinks pass `None`. pub send_deadline: Option<Duration>, } @@ -164,10 +159,9 @@ pub struct MuxOptions { /// autorip's stream event handler). Every method has a no-op default so a /// consumer overrides only what it renders. /// -/// `Send + Sync + 'static` so [`mux_stream`] can clone the handle into the -/// reader constructors' `'static` `EventFn`. Progress is split into -/// read-side and write-side callbacks (CLI renders WRITE, autorip READ). -/// See docs/mux-driver.md for the event-firing-thread details. +/// `Send + Sync + 'static` so [`mux_stream`] can clone the handle into the reader constructors' +/// `'static` `EventFn`. Progress is split into read-side and write-side callbacks (CLI renders +/// WRITE, autorip READ). pub trait MuxEvents: Send + Sync + 'static { /// Fired once, immediately after the output sink is created. fn on_output_opened(&self, _title: &DiscTitle) {} @@ -215,9 +209,9 @@ pub struct MuxOutcome { /// the `mp4://` sink, which must drop an audio track no frame of which /// yielded a parseable sample entry. /// - /// Non-empty means the file does NOT match the pre-mux plan even with - /// `completed = true`. A caller reporting a successful export must report - /// these too — a lossy outcome is never silent. See docs/mux-driver.md. + /// Non-empty means the file does NOT match the pre-mux plan even with `completed = true`. A + /// caller reporting a successful export must report these too — a lossy outcome is never + /// silent. pub undelivered_streams: Vec<usize>, } @@ -470,9 +464,9 @@ pub fn mux_stream( ) } -// Decrypt keys for the live `Session` mux of `disc`. A DVD is handed -// `DecryptKeys::None` so `DiscStream::new` cracks the CORRECT per-title CSS -// key rather than the whole-disc (largest-title) VTS key. See docs/mux-driver.md. +// Decrypt keys for the live `Session` mux of `disc`. A DVD is handed `DecryptKeys::None` so +// `DiscStream::new` cracks the CORRECT per-title CSS key rather than the whole-disc +// (largest-title) VTS key. fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys { if matches!(disc.format, crate::disc::DiscFormat::Dvd) { DecryptKeys::None @@ -481,9 +475,9 @@ fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys { } } -// Resolve the base AACS key map for an INLINE live-drive mux (`Session`/ -// `Live`) before the reader moves into `DiscStream::new`. AACS keys resolve -// to `Some(map)`; CSS/clear/`None`/`raw` return `Ok(None)`. See docs/mux-driver.md. +// Resolve the base AACS key map for an INLINE live-drive mux (`Session`/ `Live`) before the +// reader moves into `DiscStream::new`. AACS keys resolve to `Some(map)`; CSS/clear/`None`/`raw` +// return `Ok(None)`. fn resolve_inline_base_map( reader: &mut dyn SectorSource, title: &DiscTitle, @@ -503,9 +497,7 @@ fn resolve_inline_base_map( Ok(Some(Arc::new(map))) } -// Translate libfreemkv's reader-side `Event`s into `MuxEvents` calls, -// producing the `'static` `EventFn` the file highway and live `DiscStream` -// constructors require. Full event mapping: docs/mux-driver.md. +// Adapt reader events to mux progress callbacks with an owned, 'static closure. fn reader_event_fn(events: Arc<dyn MuxEvents>) -> crate::sector::prefetched::EventFn { Box::new(move |e: Event| match e.kind { EventKind::BytesRead { bytes, total } => events.on_read_progress(bytes, total), @@ -518,9 +510,8 @@ fn reader_event_fn(events: Arc<dyn MuxEvents>) -> crate::sector::prefetched::Eve }) } -// Whether a finished mux counts as COMPLETED: interrupted, finalize_failed, -// or halt_cancelled each force `false`. Pure fn so this mapping is -// unit-tested directly. See docs/mux-driver.md. +// Whether a finished mux counts as COMPLETED: interrupted, finalize_failed, or halt_cancelled +// each force `false`. Pure fn so this mapping is unit-tested directly. fn mux_run_completed(interrupted: bool, finalize_failed: bool, halt_cancelled: bool) -> bool { !(interrupted || finalize_failed || halt_cancelled) } @@ -1451,9 +1442,8 @@ mod tests { } } - // `MuxInput::Live` builds the INLINE `DiscStream` and applies the - // forensic `key_map` before reading, so odd-phase forensic units are - // NEVER fetched. See docs/mux-driver.md for the full rationale. + // `MuxInput::Live` builds the INLINE `DiscStream` and applies the forensic `key_map` before + // reading, so odd-phase forensic units are NEVER fetched. #[test] fn mux_input_live_uses_inline_discstream_and_applies_key_map() { use crate::decrypt::{AacsKeyMap, Phase}; @@ -1570,9 +1560,8 @@ mod tests { unit } - // END-TO-END decrypt on live `MuxInput::Live` with a plain AACS disc and - // NO caller key map: the `Live` arm must RESOLVE + INSTALL the base map - // itself. See docs/mux-driver.md — regression guard + mutation rationale. + // END-TO-END decrypt on live `MuxInput::Live` with a plain AACS disc and NO caller key map: + // the `Live` arm must RESOLVE + INSTALL the base map itself. #[test] fn mux_input_live_aacs_without_caller_map_resolves_and_decrypts() { use crate::disc::Extent; @@ -1660,9 +1649,8 @@ mod tests { } } - // END-TO-END decrypt on the live single-pass `MuxInput::Session` path - // (same take_reader → resolve_inline_base_map → DiscStream → - // with_key_map sequence as `Live`). See docs/mux-driver.md. + // END-TO-END decrypt on the live single-pass `MuxInput::Session` path (same take_reader → + // resolve_inline_base_map → DiscStream → with_key_map sequence as `Live`). #[test] fn mux_input_session_aacs_without_caller_map_resolves_and_decrypts() { use crate::disc::Extent; diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index 00eaf1b8..426550e9 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -147,9 +147,8 @@ pub fn start_master<W: Write + Seek>(w: &mut W, id: u32) -> io::Result<u64> { Ok(size_pos) } -// Fixed-width 8-octet EBML VINT (0x01 marker + 56-bit big-endian payload, -// RFC 8794 §4.4) used to back-patch a master element's size field. -// See docs/ebml.md — fixed_width_vint8 (rationale, callers' invariants). +// Fixed-width 8-octet EBML VINT (0x01 marker + 56-bit big-endian payload, RFC 8794 §4.4) used +// to back-patch a master element's size field. fn fixed_width_vint8(data_size: u64) -> [u8; 8] { debug_assert!(data_size < 0x0100_0000_0000_0000); [ @@ -193,8 +192,8 @@ pub fn end_master<W: Write + Seek>(w: &mut W, size_pos: u64) -> io::Result<()> { /// 8-byte size placeholder [`start_master`] writes. Returns the buffer index of /// the size field, for [`end_master_buf`]. /// -/// This is the seek-free twin of [`start_master`]/[`end_master`], producing -/// byte-for-byte identical output — see docs/ebml.md for why. +/// This is the seek-free twin of [`start_master`]/[`end_master`], producing byte-for-byte +/// identical output. pub fn start_master_buf(buf: &mut Vec<u8>, id: u32) -> io::Result<usize> { write_id(buf, id)?; let size_pos = buf.len(); @@ -415,9 +414,8 @@ pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> { read_exact_bounded(r, len) } -// Reads exactly `len` bytes without trusting `len` to size the allocation -// up front (an attacker-controlled EBML size could claim gigabytes). -// See docs/ebml.md — read_exact_bounded. +// Reads exactly `len` bytes without trusting `len` to size the allocation up front (an +// attacker-controlled EBML size could claim gigabytes). fn read_exact_bounded(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> { let mut buf = Vec::new(); let got = r.take(len as u64).read_to_end(&mut buf)?; @@ -1332,9 +1330,8 @@ mod tests { // And the whole buffer is exactly the outer element. assert_eq!(data.len() as u64, outer_body_start + osize); } - // Buffer-based and seek-based master helpers must produce byte-for-byte - // identical output; write_block_group relies on that to skip seeks/flushes. - // See docs/ebml.md — buffered_master_matches_seeking_master_byte_for_byte. + // Buffer-based and seek-based master helpers must produce byte-for-byte identical output; + // write_block_group relies on that to skip seeks/flushes. #[test] fn buffered_master_matches_seeking_master_byte_for_byte() { use std::io::Cursor; @@ -1410,9 +1407,8 @@ mod tests { ); } - // Every payload octet is distinct, so a shifted/reversed/dropped octet - // is visible; the top octets are unreachable through end_master directly. - // See docs/ebml.md — fixed_width_vint8_is_big_endian_over_the_full_payload. + // Every payload octet is distinct, so a shifted/reversed/dropped octet is visible; the top + // octets are unreachable through end_master directly. #[test] // Underscores mark bitfield boundaries (e.g. 5-bit then 3-bit), not digit // groups; regrouping uniformly would destroy the only thing they encode. diff --git a/src/mux/fmp4/mod.rs b/src/mux/fmp4/mod.rs index a3d64c1a..11fb1929 100644 --- a/src/mux/fmp4/mod.rs +++ b/src/mux/fmp4/mod.rs @@ -1,12 +1,10 @@ //! Fragmented MP4 muxer — **STUB**: fragment emission is not implemented. //! -//! Goal: ISO/IEC 14496-12 fragmented MP4 (`ftyp` + `moov` init segment, -//! then a sequence of `moof+mdat` media fragments) targeting a -//! [`SequentialSink`](crate::io::sink::SequentialSink). DASH-friendly, -//! no Cues backpatch. Emits the init segment via -//! [`Fmp4Mux::write_init_segment`]; [`Fmp4Mux::write_video`] returns -//! [`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented). See -//! docs/fmp4-mod.md for details. +//! Goal: ISO/IEC 14496-12 fragmented MP4 (`ftyp` + `moov` init segment, then a sequence of +//! `moof+mdat` media fragments) targeting a +//! [`SequentialSink`](crate::io::sink::SequentialSink). DASH-friendly, no Cues backpatch. Emits +//! the init segment via [`Fmp4Mux::write_init_segment`]; [`Fmp4Mux::write_video`] returns +//! [`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented). use std::io::{self, Write}; @@ -297,9 +295,8 @@ fn build_mvex() -> Vec<u8> { wrap_box(&MVEX, &trex) } -// Wrap a box body in `[size:u32-BE][type:4]`; oversized boxes (>4 GiB) -// need the 64-bit large-size extension, not generated in the stub. -// See docs/fmp4-mod.md — wrap_box size-cast rationale. +// Wrap a box body in `[size:u32-BE][type:4]`; oversized boxes (>4 GiB) need the 64-bit +// large-size extension, not generated in the stub. fn wrap_box(box_type: &[u8; 4], body: &[u8]) -> Vec<u8> { let total = body.len() + 8; debug_assert!(total <= u32::MAX as usize, "fMP4 box exceeds u32 size"); diff --git a/src/mux/fvi_sink.rs b/src/mux/fvi_sink.rs index c555bf91..42dd66be 100644 --- a/src/mux/fvi_sink.rs +++ b/src/mux/fvi_sink.rs @@ -4,10 +4,8 @@ //! *video-index* record per coded picture of the title's primary video //! track, instead of muxing frames into a container. //! -//! On-disk shape: the freemkv FVI format (normative spec `docs/FVI_FORMAT.md`) -//! — JSON Lines, a header object on line 1, then one record per picture. -//! The sink is purely additive: it does NOT touch the MKV mux path. -// See docs/fvi-sink.md — relationship to `VideoMap` and extensibility design. +//! On-disk shape: the freemkv FVI format — JSON Lines, a header object on line 1, then one +//! record per picture. The sink is purely additive: it does NOT touch the MKV mux path. use crate::disc::{DiscTitle, Stream as DiscStream}; use crate::mux::videomap::{ @@ -19,7 +17,7 @@ use std::fs::File; use std::io::{self, BufWriter, Write}; use std::path::Path; -/// Write the FVI header row (JSON Lines, `docs/FVI_FORMAT.md` §6) into `w`. +/// Write the FVI header row into `w`. fn write_fvi_header(w: &mut dyn Write, h: &MapHeader) -> io::Result<()> { let mut source = serde_json::json!({ "medium": h.source.medium.as_str(), @@ -64,9 +62,8 @@ fn write_fvi_header(w: &mut dyn Write, h: &MapHeader) -> io::Result<()> { w.write_all(b"\n") } -// Write one FVI per-picture record (JSON Lines, `docs/FVI_FORMAT.md` §7). -// `type`/`key` are always emitted; coding-derived members are emitted only -// when the codec measured them — an honest absence, never a guessed default. +// Write one FVI per-picture record. `type`/`key` are always emitted; coding-derived members are +// emitted only when the codec measured them — an honest absence, never a guessed default. fn write_fvi_record(w: &mut dyn Write, r: &PictureRecord) -> io::Result<()> { // `src` is REQUIRED (Appendix A); null when provenance absent = "position // unknown". Per §9, `src.byte` is the offset WITHIN the sector, so reduce the @@ -90,9 +87,8 @@ fn write_fvi_record(w: &mut dyn Write, r: &PictureRecord) -> io::Result<()> { if let Some(pts) = r.pts_ns { obj["pts"] = serde_json::json!(pts); } - // dts: MAY, always omitted. See docs/fvi-sink.md for the TODO(provenance→recovery join) note. + // dts: MAY, always omitted. - // See docs/fvi-sink.md — coding-derived record members (§7.1). if let Some(c) = r.coding { if let Some(fo) = field_order_label(r.coding) { obj["field_order"] = serde_json::json!(fo); @@ -129,12 +125,11 @@ impl FviSink { /// Create the sink at `path`, assembling the header from `title`'s primary /// video stream. /// - /// `source` records where the index was built FROM — the input medium, URL, - /// title index and (when known) playlist / volume id. It is carried verbatim - /// into the header's `source` object (`docs/FVI_FORMAT.md` §6.2), which - /// describes the INPUT, never `path` (the destination this sink writes). - /// A caller with no provenance to declare passes `SourceInfo::default()`; - /// the empty members are then omitted from the header rather than guessed. + /// `source` records where the index was built FROM — the input medium, URL, title index and + /// (when known) playlist / volume id. It is carried verbatim into the header's `source` + /// object, which describes the INPUT, never `path` (the destination this sink writes). A + /// caller with no provenance to declare passes `SourceInfo::default()`; the empty members + /// are then omitted from the header rather than guessed. pub fn create(path: &Path, title: &DiscTitle, source: SourceInfo) -> io::Result<Self> { let file = File::create(path)?; diff --git a/src/mux/hevc/mod.rs b/src/mux/hevc/mod.rs index 36681b3b..fd70f19d 100644 --- a/src/mux/hevc/mod.rs +++ b/src/mux/hevc/mod.rs @@ -1,9 +1,7 @@ //! HEVC (H.265) elementary stream muxer — Annex B byte stream. //! -//! Consumes [`PesFrame`](crate::pes::PesFrame)s for a single video track -//! and writes them as a raw `.hevc` / `.h265` Annex B byte stream with no -//! container framing. See docs/hevc-mux.md for the stream layout and -//! parameter-set handling. +//! Consumes [`PesFrame`](crate::pes::PesFrame)s for a single video track and writes them as a +//! raw `.hevc` / `.h265` Annex B byte stream with no container framing. //! //! Sequential-only — no Cues, no backpatch. Target sink is any //! [`SequentialSink`](crate::io::sink::SequentialSink): file, socket, @@ -60,10 +58,9 @@ impl<W: Write> HevcMux<W> { /// Write one PES frame (= one access unit) as Annex B NAL units. /// - /// Input may be either length-prefixed (`[u32-BE len][NAL bytes]` - /// repeated, the form libfreemkv's HEVC parser emits) — converted to - /// Annex B — or already Annex B (a buffer beginning with a start - /// code), passed through unchanged. See docs/hevc-mux.md for detail. + /// Input may be either length-prefixed (`[u32-BE len][NAL bytes]` repeated, the form + /// libfreemkv's HEVC parser emits) — converted to Annex B — or already Annex B (a buffer + /// beginning with a start code), passed through unchanged. /// /// `_pts_ns` is accepted for symmetry with other muxers but ignored /// — Annex B has no timing layer. @@ -106,9 +103,9 @@ impl<W: Write> HevcMux<W> { } } -// Convert a HEVCDecoderConfigurationRecord (hvcC) into Annex B NAL units. -// Single source of truth for hvcC -> Annex B across all muxers (HEVC ES, -// BD-TS, standard MPEG-TS) — do not reimplement. See docs/hevc-mux.md. +// Convert a HEVCDecoderConfigurationRecord (hvcC) into Annex B NAL units. Single source of +// truth for hvcC -> Annex B across all muxers (HEVC ES, BD-TS, standard MPEG-TS) — do not +// reimplement. pub(crate) fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> { if hvcc.len() < 23 { return None; @@ -151,9 +148,9 @@ pub(crate) fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> { if out.is_empty() { None } else { Some(out) } } -// Convert length-prefixed NALs ([u32-BE len][NAL] repeated) to Annex B; -// already-Annex-B input passes through unchanged. Truncation policy -// (shared across muxers): drop truncated trailing NAL. See docs/hevc-mux.md. +// Convert length-prefixed NALs ([u32-BE len][NAL] repeated) to Annex B; already-Annex-B input +// passes through unchanged. Truncation policy (shared across muxers): drop truncated trailing +// NAL. pub(crate) fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> { // Probe for a leading Annex B start code before attempting to parse // length prefixes: `00 00 00 01` would otherwise parse as length 1. @@ -169,9 +166,8 @@ pub(crate) fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> { /// ISO/IEC 14496-15 records declare as `lengthSizeMinusOne = 3`. pub(crate) const DEFAULT_NAL_LENGTH_SIZE: usize = 4; -// Octets per NAL length prefix for `record`'s track, per avcC/hvcC's -// lengthSizeMinusOne field (ISO/IEC 14496-15). Falls back to -// DEFAULT_NAL_LENGTH_SIZE. See docs/hevc-mux.md for field offsets and rationale. +// Octets per NAL length prefix for `record`'s track, per avcC/hvcC's lengthSizeMinusOne field +// (ISO/IEC 14496-15). Falls back to DEFAULT_NAL_LENGTH_SIZE. pub(crate) fn nal_length_size(codec: crate::disc::Codec, record: Option<&[u8]>) -> usize { use crate::disc::Codec; let field_offset = match codec { @@ -185,16 +181,15 @@ pub(crate) fn nal_length_size(codec: crate::disc::Codec, record: Option<&[u8]>) } } -// Annex B form of length-prefixed `data`, written into caller-owned `out` -// to avoid an allocation on hot per-frame paths. Non-length-prefixed input -// is appended unchanged (assumed already Annex B). See docs/hevc-mux.md. +// Annex B form of length-prefixed `data`, written into caller-owned `out` to avoid an +// allocation on hot per-frame paths. Non-length-prefixed input is appended unchanged (assumed +// already Annex B). pub(crate) fn append_length_prefixed_as_annex_b(out: &mut Vec<u8>, data: &[u8]) { append_length_prefixed_as_annex_b_sized(out, data, DEFAULT_NAL_LENGTH_SIZE); } -// Like append_length_prefixed_as_annex_b but for `length_size`-octet NAL -// prefixes (from nal_length_size); `length_size` outside 1..=4 clamps to -// DEFAULT_NAL_LENGTH_SIZE. See docs/hevc-mux.md. +// Like append_length_prefixed_as_annex_b but for `length_size`-octet NAL prefixes (from +// nal_length_size); `length_size` outside 1..=4 clamps to DEFAULT_NAL_LENGTH_SIZE. pub(crate) fn append_length_prefixed_as_annex_b_sized( out: &mut Vec<u8>, data: &[u8], @@ -248,9 +243,8 @@ fn starts_with_start_code(data: &[u8]) -> bool { data.starts_with(&START_CODE) || data.starts_with(&[0x00, 0x00, 0x01]) } -// Convert an AVCDecoderConfigurationRecord (avcC) into Annex B NAL units; -// H.264 counterpart to hvcc_to_annex_b and single source of truth for -// avcC -> Annex B across all muxers. See docs/hevc-mux.md for layout. +// Convert an AVCDecoderConfigurationRecord (avcC) into Annex B NAL units; H.264 counterpart to +// hvcc_to_annex_b and single source of truth for avcC -> Annex B across all muxers. pub(crate) fn avcc_to_annex_b(avcc: &[u8]) -> Option<Vec<u8>> { // avcC fixed header is 5 bytes; byte 5 carries the SPS count (low 5 bits), // then the SPS array begins at byte 6 (ISO/IEC 14496-15 §5.3.3.1.2). diff --git a/src/mux/m2ts.rs b/src/mux/m2ts.rs index 2afe0dad..f14b7d12 100644 --- a/src/mux/m2ts.rs +++ b/src/mux/m2ts.rs @@ -164,9 +164,8 @@ mod tests { } } - // avcC (not hvcC) must parse via the avcC parser so SPS/PPS reach the - // player as Annex-B; otherwise the ES is silently undecodable. - // See docs/m2ts.md — h264_avcc_parameter_sets_are_emitted_as_annex_b + // avcC (not hvcC) must parse via the avcC parser so SPS/PPS reach the player as Annex-B; + // otherwise the ES is silently undecodable. #[test] fn h264_avcc_parameter_sets_are_emitted_as_annex_b() { let sps: &[u8] = &[0x67, 0x42, 0xC0, 0x1E, 0xAB, 0xCD]; @@ -222,9 +221,8 @@ mod tests { ); } - // create() must opt VC-1 video OUT of Annex-B conversion; this pins the - // wiring in create() itself, not just TsMuxer's flag. - // See docs/m2ts.md — vc1_video_is_wired_to_the_non_nal_path + // create() must opt VC-1 video OUT of Annex-B conversion; this pins the wiring in create() + // itself, not just TsMuxer's flag. #[test] fn vc1_video_is_wired_to_the_non_nal_path() { let mut title = make_title(); diff --git a/src/mux/m2ts_mux/mod.rs b/src/mux/m2ts_mux/mod.rs index a4d29d0d..91c8897d 100644 --- a/src/mux/m2ts_mux/mod.rs +++ b/src/mux/m2ts_mux/mod.rs @@ -19,8 +19,6 @@ //! [adaptation field, if signalled] //! [payload, if signalled] //! ``` -//! -//! See docs/m2ts-mux.md for PID wiring, PSI/PCR cadence, and scope notes. use std::io::{self, Write}; @@ -246,9 +244,8 @@ impl<W: Write> M2tsMux<W> { self.out.flush() } - // PTS (ns) -> 90 kHz ticks rebased on the stream origin, seeded by the FIRST - // frame of any kind (video or audio) when `may_seed_base`. See - // docs/m2ts-mux.md#base_relative_pts. + // PTS (ns) -> 90 kHz ticks rebased on the stream origin, seeded by the FIRST frame of any + // kind (video or audio) when `may_seed_base`. fn base_relative_pts(&mut self, pts_ns: i64, may_seed_base: bool) -> u64 { let raw_90k = if pts_ns > 0 { // Widen to u128 so adversarial timestamps can't overflow the @@ -272,9 +269,8 @@ impl<W: Write> M2tsMux<W> { if delta >= (1 << 32) { 0 } else { delta } } - // Emit one PES payload as a chain of TS packets on `pid`; `pcr`, if - // given, is stamped on the first packet's AF. See docs/m2ts-mux.md - // #write_pes-packet-size-math for the 188/184-byte layout math. + // Emit one PES payload as a chain of TS packets on `pid`; `pcr`, if given, is stamped on + // the first packet's AF. fn write_pes( &mut self, pid: u16, @@ -574,8 +570,6 @@ mod tests { use super::*; // Golden vector for an audio PES header (ISO/IEC 13818-1 §2.4.3.7). - // See docs/m2ts-mux.md#audio_pes_header_is_byte_exact_and_bounded_unlike_video - // for why the stream_id and PES_packet_length fields matter here. #[test] fn audio_pes_header_is_byte_exact_and_bounded_unlike_video() { let es = [0xDEu8, 0xAD, 0xBE, 0xEF]; @@ -873,7 +867,6 @@ mod tests { // Regression: a silent no-op parameter-set prepend must be observable via // `parameter_sets_emitted()`, not just a warning. - // See docs/m2ts-mux.md#absent_or_unparseable_codec_private_is_reported_not_silent. #[test] fn absent_or_unparseable_codec_private_is_reported_not_silent() { // A VPS NAL the parser can find, and the hvcC that carries it. @@ -997,7 +990,6 @@ mod tests { } // Regression: PCR must be re-stamped MID-PES, not only at PES boundaries. - // See docs/m2ts-mux.md#pcr_restamped_mid_pes_within_interval. #[test] fn pcr_restamped_mid_pes_within_interval() { let mut sink: Vec<u8> = Vec::new(); diff --git a/src/mux/m2ts_mux/packet.rs b/src/mux/m2ts_mux/packet.rs index 8c12aba7..06578745 100644 --- a/src/mux/m2ts_mux/packet.rs +++ b/src/mux/m2ts_mux/packet.rs @@ -47,9 +47,7 @@ impl Packet { self.len += n; } - // Write the 4-byte TS packet header (pid, payload_unit_start, - // has_payload, has_adaptation, cc — see docs/m2ts-mux.md for the - // per-field bit layout). + // Write the 4-byte TS packet header. pub(super) fn set_header( &mut self, pid: u16, @@ -74,9 +72,8 @@ impl Packet { self.push((afc << 4) | (cc & 0x0F)); } - // Append the adaptation field after the header: `body` is the field body - // (flags + optional PCR etc.), `stuffing` is the trailing 0xFF count. - // See docs/m2ts-mux.md for the length-byte and error contract. + // Append the adaptation field after the header: `body` is the field body (flags + optional + // PCR etc.), `stuffing` is the trailing 0xFF count. pub(super) fn append_adaptation(&mut self, body: &[u8], stuffing: usize) -> io::Result<()> { let af_len = body.len() + stuffing; if af_len > MAX_AF_LEN { @@ -354,9 +351,8 @@ mod tests { } } - // PacketWriter buffers nothing itself, so flush() is the only thing - // that empties the sink's buffer — skipping it truncates the stream - // mid-packet. See docs/m2ts-mux.md for the full failure mode. + // PacketWriter buffers nothing itself, so flush() is the only thing that empties the sink's + // buffer — skipping it truncates the stream mid-packet. #[test] fn flush_delivers_the_buffered_packets_to_the_sink() { let sink = SharedSink::default(); diff --git a/src/mux/meta_sink.rs b/src/mux/meta_sink.rs index de464f03..315e3ba8 100644 --- a/src/mux/meta_sink.rs +++ b/src/mux/meta_sink.rs @@ -106,7 +106,6 @@ impl Stream for ChaptersSink { // ── json:// ────────────────────────────────────────────────────────────────── // The `json://` document for one title, built from [`TitleProfile`]. -// See docs/meta-sink.md — title_json schema. pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value { use serde_json::json; // `index`/`is_main` are disc-level (title's position in the sorted list, whether @@ -470,7 +469,6 @@ mod tests { } // Write-only sinks must refuse read() with E_STREAM_WRITE_ONLY, not Ok(None). - // See docs/meta-sink.md — write-only sinks: read() semantics. #[test] fn metadata_sinks_refuse_to_be_read_from() { let code = format!("E{}", crate::error::Error::StreamWriteOnly.code()); diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index bf1e056e..6d9a14a5 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -69,17 +69,15 @@ const COLOUR_RANGE_LIMITED: u8 = 1; /// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec). const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343; -// BlockAddIDType "mvcC" — MVCDecoderConfigurationRecord fourcc, big-endian -// ASCII 'm''v''c''C'. See docs/mkv-mux.md — Blu-ray 3D MVC BlockAddition mapping. +// BlockAddIDType "mvcC" — MVCDecoderConfigurationRecord fourcc, big-endian ASCII 'm''v''c''C'. const BLOCK_ADD_ID_TYPE_MVCC: u64 = 0x6D76_6343; /// BlockAddIDValue for the MVC mapping — the value each per-frame `BlockAddID` /// references (RFC 9559 requires ≥ 2; 1 is the default plain BlockAdditional). const BLOCK_ADD_ID_VALUE_MVC: u64 = 2; -// Build an MVCDecoderConfigurationRecord (BlockAddIDExtraData for the mvcC -// mapping). `None` if either param set is absent, too short, or too long. -// See docs/mkv-mux.md — MVC decoder config record layout. +// Build an MVCDecoderConfigurationRecord (BlockAddIDExtraData for the mvcC mapping). `None` if +// either param set is absent, too short, or too long. fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> { if subset_sps.len() < 4 || subset_sps.len() > 0xFFFF || pps.is_empty() || pps.len() > 0xFFFF { return None; @@ -106,9 +104,8 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> { Some(record) } -// Build the CodecPrivate for an MVC base track: avcc + mvcC extension block -// (Matroska Codec Specifications §4.3.9). See docs/mkv-mux.md — MVC -// CodecPrivate layout. +// Build the CodecPrivate for an MVC base track: avcc + mvcC extension block (Matroska Codec +// Specifications §4.3.9). fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> { let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size − 4 let mut out = Vec::with_capacity(avcc.len() + 8 + record.len()); @@ -119,9 +116,8 @@ fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> { out } -// Resolve a video stream's CICP colour code points (matrix, transfer, -// primaries, range) with a single precedence shared by every sink so they -// never drift. See docs/mkv-mux.md — CICP colour resolution precedence. +// Resolve a video stream's CICP colour code points (matrix, transfer, primaries, range) with a +// single precedence shared by every sink so they never drift. pub(crate) fn cicp_for_video(v: &VideoStream) -> (u8, u8, u8, u8) { if let Some(c) = v.measured_cicp { return (c.matrix, c.transfer, c.primaries, c.range); @@ -250,9 +246,8 @@ const HDR10_CHROMATICITY_UNIT: f64 = 0.00002; /// floats in cd/m², so the conversion is `value * 0.0001`. const HDR10_LUMINANCE_UNIT: f64 = 0.0001; -// Emit the HDR10 static-metadata children of Colour: MasteringMetadata + -// MaxCLL/MaxFALL. Called only when measured from bitstream SEI. See -// docs/mkv-mux.md — HDR10 unit conversions and SEI primary order. +// Emit the HDR10 static-metadata children of Colour: MasteringMetadata + MaxCLL/MaxFALL. Called +// only when measured from bitstream SEI. fn write_hdr10<W: Write + Seek>(w: &mut W, h: &crate::mux::codec::Hdr10Metadata) -> io::Result<()> { let chroma = |v: u16| -> f64 { v as f64 * HDR10_CHROMATICITY_UNIT }; let lum = |v: u32| -> f64 { v as f64 * HDR10_LUMINANCE_UNIT }; @@ -308,9 +303,8 @@ fn write_hdr10<W: Write + Seek>(w: &mut W, h: &crate::mux::codec::Hdr10Metadata) Ok(()) } -// RFC 9559 §12 Language is ISO 639-2; empty means "no language stated" → -// `und`. Single decision point so every scanner doesn't repeat the default. -// See docs/mkv-mux.md — Language element default rationale. +// RFC 9559 §12 Language is ISO 639-2; empty means "no language stated" → `und`. Single decision +// point so every scanner doesn't repeat the default. fn language_or_und(lang: &str) -> String { if lang.is_empty() { "und".to_string() @@ -526,12 +520,10 @@ pub struct MkvMuxer<W: Write + Seek> { cluster_pos: u64, cluster_size_pos: u64, cluster_ts_ticks: i64, - /// Reusable scratch buffer for assembling ONE BlockGroup before it is - /// written with a single `write_all`; building it in memory means its - /// size is known before it reaches the file, so no `start_master`/ - /// `end_master` back-patch seek is needed. Kept on the muxer so the - /// allocation is made once, not per frame. See docs/mkv-mux.md — why - /// BlockGroup buffering avoids per-frame seeks. + /// Reusable scratch buffer for assembling ONE BlockGroup before it is written with a single + /// `write_all`; building it in memory means its size is known before it reaches the file, + /// so no `start_master`/ `end_master` back-patch seek is needed. Kept on the muxer so the + /// allocation is made once, not per frame. block_group_buf: Vec<u8>, base_pts_ticks: Option<i64>, /// Last block timecode (TimestampScale ticks, relative to base_pts) written @@ -571,12 +563,10 @@ pub struct MkvMuxer<W: Write + Seek> { continuity: TimelineContinuity, cues: Vec<CuePoint>, frame_count: u64, - /// Frames handed to `write_frame` that were dropped because no cluster was - /// open yet (opens only on a keyframe from the primary video track). The - /// ALL-dropped case is surfaced by `finish()` via `frame_count == 0`, not - /// this counter; a PARTIAL drop is normal and is only logged, not an - /// error — `finish()` is this field's only reader, so keep that log. See - /// docs/mkv-mux.md — dropped_pre_cluster logging rationale. + /// Frames handed to `write_frame` that were dropped because no cluster was open yet (opens + /// only on a keyframe from the primary video track). The ALL-dropped case is surfaced by + /// `finish()` via `frame_count == 0`, not this counter; a PARTIAL drop is normal and is + /// only logged, not an error — `finish()` is this field's only reader, so keep that log. dropped_pre_cluster: u64, seek_fixups: Vec<SeekPositionFixup>, /// Absolute file offset of the CUES SeekHead entry (a fixed 21-byte Seek @@ -701,9 +691,9 @@ struct InterlacedRewrite { field_order_span: Option<(u64, u64)>, } -// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 -// ns) avoids collisions the classic 1 ms scale causes for B-frame-reordered -// video and TrueHD's 0.833 ms AUs. See docs/mkv-mux.md — TimestampScale choice. +// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns) avoids +// collisions the classic 1 ms scale causes for B-frame-reordered video and TrueHD's 0.833 ms +// AUs. const TIMESTAMP_SCALE_NS: i64 = 100_000; // Last-resort BlockDuration (1 s) for a SUBTITLE frame that reaches the muxer with @@ -711,21 +701,18 @@ const TIMESTAMP_SCALE_NS: i64 = 100_000; // subtitle path forgets, keeping every subtitle block durated (issue #52). const MIN_SUBTITLE_BLOCK_DURATION_NS: i64 = 1_000_000_000; -// Nominal new-cluster interval (2 s) in TimestampScale ticks; a keyframe -// only opens a new cluster once this much has elapsed. See docs/mkv-mux.md -// — cluster duration vs. i16 block-relative limit. +// Nominal new-cluster interval (2 s) in TimestampScale ticks; a keyframe only opens a new +// cluster once this much has elapsed. const CLUSTER_DURATION_TICKS: i64 = 2_000 * 1_000_000 / TIMESTAMP_SCALE_NS; -// Maximum block-relative timestamp in the signed 16-bit SimpleBlock/Block -// field; a frame outside this forces a new cluster so `as i16` never wraps. -// See docs/mkv-mux.md — MAX_BLOCK_REL / MIN_BLOCK_REL rationale. +// Maximum block-relative timestamp in the signed 16-bit SimpleBlock/Block field; a frame +// outside this forces a new cluster so `as i16` never wraps. const MAX_BLOCK_REL: i64 = i16::MAX as i64; /// Minimum block-relative timestamp expressible in the signed 16-bit field. const MIN_BLOCK_REL: i64 = i16::MIN as i64; -// Force a per-track block timestamp to be strictly later than `prev` (the -// track's last timestamp, `None` for the first frame); never moves it -// earlier. See docs/mkv-mux.md — monotonic_ts non-monotonic DTS rationale. +// Force a per-track block timestamp to be strictly later than `prev` (the track's last +// timestamp, `None` for the first frame); never moves it earlier. fn monotonic_ts(prev: Option<i64>, pts_ticks: i64) -> i64 { match prev { Some(p) => pts_ticks.max(p.saturating_add(1)), @@ -733,9 +720,8 @@ fn monotonic_ts(prev: Option<i64>, pts_ticks: i64) -> i64 { } } -// Per-track block timestamp: monotonic nudge applies to AUDIO/SUBTITLE only; -// video is UNCHANGED (B-frame PTS is legitimately non-monotonic), keyed on -// `is_video` not track index. See docs/mkv-mux.md — block_ts rationale. +// Per-track block timestamp: monotonic nudge applies to AUDIO/SUBTITLE only; video is UNCHANGED +// (B-frame PTS is legitimately non-monotonic), keyed on `is_video` not track index. fn block_ts(is_video: bool, prev: Option<i64>, pts_ticks: i64) -> i64 { if is_video { pts_ticks @@ -744,9 +730,8 @@ fn block_ts(is_video: bool, prev: Option<i64>, pts_ticks: i64) -> i64 { } } -// Encode a Matroska track number as an EBML VINT into a stack buffer (no -// heap alloc; hot path). 1/2/3-byte widths per VINT marker bit; handled in -// RELEASE (not just debug_assert). See docs/mkv-mux.md — track_vint widths. +// Encode a Matroska track number as an EBML VINT into a stack buffer (no heap alloc; hot path). +// 1/2/3-byte widths per VINT marker bit; handled in RELEASE (not just debug_assert). fn track_vint(track_num: usize) -> ([u8; 3], usize) { if track_num < 0x80 { ([(track_num as u8) | 0x80, 0, 0], 1) @@ -952,8 +937,7 @@ impl<W: Write + Seek> MkvMuxer<W> { None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?, } } - // Pre-0.13's deferred codecPrivate path was removed as dead code; - // see docs/mkv-mux.md — codecPrivate path history. + // Pre-0.13's deferred codecPrivate path was removed as dead code. // DefaultDuration — frame duration in nanoseconds if track.default_duration_ns > 0 { @@ -1199,12 +1183,10 @@ impl<W: Write + Seek> MkvMuxer<W> { pub fn set_opening_capture(&mut self, capture: Option<crate::diag::OpeningCapture>) { self.opening_capture = capture; } - /// Drive seam correction from the title's PlayItem marks instead of - /// inferring it from PTS jumps. Each clip is placed at the sum of the - /// earlier clips' durations, so output runs exactly as long as the - /// playlist says. No-op for fewer than two clips or without usable - /// marks — DVD, HD-DVD and file sources keep the inference path. See - /// docs/mkv-mux.md — set_clips seam-correction rationale. + /// Drive seam correction from the title's PlayItem marks instead of inferring it from PTS + /// jumps. Each clip is placed at the sum of the earlier clips' durations, so output runs + /// exactly as long as the playlist says. No-op for fewer than two clips or without usable + /// marks — DVD, HD-DVD and file sources keep the inference path. pub fn set_clips( &mut self, clips: &[crate::disc::Clip], @@ -1245,11 +1227,9 @@ impl<W: Write + Seek> MkvMuxer<W> { /// which clip it came from under a seam plan (ambiguous by timestamp /// alone inside an overlap). Pass `frame.source.map(|s| s.byte)`. /// - /// `block_additional`, when `Some`, is a Matroska `BlockAdditional` - /// (BlockAddID=2) for Blu-ray 3D MVC dependent-view data; such a frame - /// is always a `BlockGroup` with a `ReferenceBlock` when not a - /// keyframe. `None` for non-3D. See docs/mkv-mux.md — write_frame_at - /// seam/MVC BlockAdditional rationale. + /// `block_additional`, when `Some`, is a Matroska `BlockAdditional` (BlockAddID=2) for + /// Blu-ray 3D MVC dependent-view data; such a frame is always a `BlockGroup` with a + /// `ReferenceBlock` when not a keyframe. `None` for non-3D. #[allow(clippy::too_many_arguments)] #[cfg(test)] pub fn write_frame_at( @@ -1576,12 +1556,10 @@ impl<W: Write + Seek> MkvMuxer<W> { /// Finish the MKV file: write Cues element. /// - /// A cluster only opens on a keyframe from the PRIMARY VIDEO TRACK, not - /// necessarily index 0 (see `cluster_driver`). The caller must deliver a - /// keyframe on that track before/alongside other-track data, or every - /// `write_frame` is silently dropped; `finish` then returns - /// `Error::MkvInvalid` rather than emit a structurally valid but empty - /// MKV. See docs/mkv-mux.md — finish() cluster-driver invariant. + /// A cluster only opens on a keyframe from the PRIMARY VIDEO TRACK, not necessarily index 0 + /// (see `cluster_driver`). The caller must deliver a keyframe on that track + /// before/alongside other-track data, or every `write_frame` is silently dropped; `finish` + /// then returns `Error::MkvInvalid` rather than emit a structurally valid but empty MKV. pub fn finish(mut self) -> io::Result<()> { // Order matters: a title the seam plan dropped ENTIRELY also has zero frames, // and `MkvInvalid` is classified as a skippable empty nav/menu stub — so that @@ -1897,9 +1875,8 @@ impl<W: Write + Seek> MkvMuxer<W> { Ok(()) } - // Write a BlockGroup (Block + BlockDuration, plus ReferenceBlock when not - // a keyframe). Not subtitle-only: every MPEG-2 I/P/B frame arrives here. - // See docs/mkv-mux.md — write_block_group ReferenceBlock/keyframe-bit. + // Write a BlockGroup (Block + BlockDuration, plus ReferenceBlock when not a keyframe). Not + // subtitle-only: every MPEG-2 I/P/B frame arrives here. fn write_block_group( &mut self, track_num: usize, @@ -1977,9 +1954,8 @@ impl<W: Write + Seek> MkvMuxer<W> { Ok(()) } - // Write a BlockGroup: base view Block + MVC dependent AU as a - // BlockAdditional (BlockAddID=2) per the track's mvcC mapping. - // See docs/mkv-mux.md — write_block_group_mvc reference/duration. + // Write a BlockGroup: base view Block + MVC dependent AU as a BlockAdditional + // (BlockAddID=2) per the track's mvcC mapping. fn write_block_group_mvc( &mut self, track_num: usize, @@ -2007,17 +1983,14 @@ impl<W: Write + Seek> MkvMuxer<W> { } } -// ============================================================ -// Helpers — see docs/mkv-mux.md for removed parse_resolution/parse_sample_rate/parse_channels history -// ============================================================ +// ============================================================ Helpers. #[cfg(test)] mod tests { use super::*; use std::io::Cursor; - // Keyframe flags must survive a write→read round-trip for frames that - // carry a per-frame DURATION (the BlockGroup path). Regression test; see - // docs/mkv-mux.md — keyframe_survives_roundtrip regression history. + // Keyframe flags must survive a write→read round-trip for frames that carry a per-frame + // DURATION (the BlockGroup path). Regression test. #[test] fn keyframe_survives_roundtrip_for_duration_bearing_frames() { let v = VideoStream { @@ -2146,9 +2119,8 @@ mod tests { ); } - // End-to-end through the REAL MkvStream create/write/finish path: audio - // frames precede the first video frame, as the DVD pipeline produces. - // See docs/mkv-mux.md — mkvstream preroll test. + // End-to-end through the REAL MkvStream create/write/finish path: audio frames precede the + // first video frame, as the DVD pipeline produces. #[test] fn mkvstream_preserves_video_keyframe_after_audio_preroll() { let v = VideoStream { @@ -2675,9 +2647,8 @@ mod tests { ); } - // Frames dropped before the first cluster opens must be COUNTED, and the - // count must survive to `finish()`. Regression test; see docs/mkv-mux.md - // — frames_dropped_before_first_cluster_are_counted rationale. + // Frames dropped before the first cluster opens must be COUNTED, and the count must survive + // to `finish()`. Regression test. #[test] fn frames_dropped_before_first_cluster_are_counted() { let buf = Cursor::new(Vec::new()); @@ -2733,9 +2704,8 @@ mod tests { ); } - // When no Cues element is written, the SeekHead must NOT retain a CUES - // entry that back-patches to a dangling offset; finish() Voids it. See - // docs/mkv-mux.md — zero_cues_voids_seekhead_entry rationale. + // When no Cues element is written, the SeekHead must NOT retain a CUES entry that + // back-patches to a dangling offset; finish() Voids it. #[test] fn zero_cues_voids_seekhead_entry_no_dangling_pointer() { use std::sync::{Arc, Mutex}; @@ -2866,9 +2836,8 @@ mod tests { ); } - // Regression for the second-video-track bug: a Dolby Vision EL is video - // but NOT track 0; the exemption must follow track TYPE. See - // docs/mkv-mux.md — second_video_track_pts_not_clobbered rationale. + // Regression for the second-video-track bug: a Dolby Vision EL is video but NOT track 0; + // the exemption must follow track TYPE. #[test] fn second_video_track_pts_not_clobbered() { use std::io::Cursor; @@ -2883,9 +2852,8 @@ mod tests { assert_eq!(block_ts(mux.track_is_video[1], Some(1040), 1000), 1000); } - // End-to-end regression: a large clip-boundary reset with an interleaved - // straggler audio frame, driven through the full muxer. See - // docs/mkv-mux.md — clip_boundary_with_straggler test rationale. + // End-to-end regression: a large clip-boundary reset with an interleaved straggler audio + // frame, driven through the full muxer. #[test] fn clip_boundary_with_straggler_yields_monotonic_clusters() { let tracks = [make_video_track(), make_audio_track()]; @@ -2935,9 +2903,8 @@ mod tests { ); } - // Regression for the hardcoded-track-0 epoch driver: M2TS/PMT may list - // audio before video, so the driver must follow PRIMARY VIDEO, not index - // 0. See docs/mkv-mux.md — epoch_driver_follows_primary_video rationale. + // Regression for the hardcoded-track-0 epoch driver: M2TS/PMT may list audio before video, + // so the driver must follow PRIMARY VIDEO, not index 0. #[test] fn epoch_driver_follows_primary_video_not_index_zero() { // Audio FIRST (index 0), video SECOND (index 1) — the M2TS/PMT ordering. @@ -3164,9 +3131,8 @@ mod tests { std::str::from_utf8(&data[pos + 4..pos + 4 + len]).unwrap() } - // RFC 9559 §12 restricts `Language` to ISO 639-2, never ISO 639-1; the - // DVD IFO block carries raw ISO 639-1 on disc. See docs/mkv-mux.md — - // dvd_two_letter_language_becomes_iso_639_2 rationale. + // RFC 9559 §12 restricts `Language` to ISO 639-2, never ISO 639-1; the DVD IFO block + // carries raw ISO 639-1 on disc. #[test] fn dvd_two_letter_language_becomes_iso_639_2_in_language_element() { // AC-3 (coding_mode=0), 48 kHz, 6 channels, on-disc language "en" — @@ -3207,9 +3173,8 @@ mod tests { ); } - // A source with no language leaves `language` EMPTY, but the muxer must - // never write a zero-length `Language`. See docs/mkv-mux.md — - // a_source_with_no_language_emits_und rationale. + // A source with no language leaves `language` EMPTY, but the muxer must never write a + // zero-length `Language`. #[test] fn a_source_with_no_language_emits_und_not_an_empty_language_element() { // Exactly what `disc::hddvd::probe_evo_streams` builds. @@ -3251,9 +3216,8 @@ mod tests { } } - // An unmapped/absent DVD language code must degrade to `und`, never an - // empty string or raw 2-letter code. See docs/mkv-mux.md — - // dvd_unmapped_or_empty_language_becomes_und rationale. + // An unmapped/absent DVD language code must degrade to `und`, never an empty string or raw + // 2-letter code. #[test] fn dvd_unmapped_or_empty_language_becomes_und_in_language_element() { // Empty IFO language bytes (0x00 0x00). @@ -3305,9 +3269,8 @@ mod tests { } } - // Build an 8-byte DVD IFO audio-attribute block carrying `code`, run it - // through the real DVD pipeline, and return the emitted Matroska - // `Language` value. See docs/mkv-mux.md — emitted_language_for_dvd_code. + // Build an 8-byte DVD IFO audio-attribute block carrying `code`, run it through the real + // DVD pipeline, and return the emitted Matroska `Language` value. fn emitted_language_for_dvd_code(code: &[u8; 2]) -> String { let mut attr_bytes = vec![0u8; 8]; attr_bytes[0] = 0x00; // AC-3, 48 kHz @@ -3334,9 +3297,8 @@ mod tests { first_language_value(&data).to_string() } - // ISO 639-1 -> 639-2 conversion must cover the WHOLE of ISO 639-1, not - // just the menu-graphic vocabulary. See docs/mkv-mux.md — - // dvd_language_outside_the_menu_vocabulary rationale. + // ISO 639-1 -> 639-2 conversion must cover the WHOLE of ISO 639-1, not just the + // menu-graphic vocabulary. #[test] fn dvd_language_outside_the_menu_vocabulary_is_still_mapped() { assert_eq!( @@ -3372,9 +3334,8 @@ mod tests { } } - // DVD-Video froze its language list at 1988 ISO 639-1 (Hebrew `iw`, - // Indonesian `in`, Yiddish `ji`). See docs/mkv-mux.md — - // dvd_era_language_aliases_map_to_the_modern_code rationale. + // DVD-Video froze its language list at 1988 ISO 639-1 (Hebrew `iw`, Indonesian `in`, + // Yiddish `ji`). #[test] fn dvd_era_language_aliases_map_to_the_modern_code() { assert_eq!( @@ -3390,9 +3351,8 @@ mod tests { assert_eq!(emitted_language_for_dvd_code(b"yi"), "yid"); } - // Widening the table must not weaken the degradation guarantee: a code - // not in ISO 639-1 still yields exactly `und`. See docs/mkv-mux.md — - // unknown_dvd_language_still_yields_exactly_und rationale. + // Widening the table must not weaken the degradation guarantee: a code not in ISO 639-1 + // still yields exactly `und`. #[test] fn unknown_dvd_language_still_yields_exactly_und() { for code in [b"zz", b"qq", b"xx"] { @@ -3498,9 +3458,8 @@ mod tests { }) } - // A wrong vendor forced label is CLEARED by the content, when a sibling - // track proves the authoring house sets `forced_on_flag`. See - // docs/mkv-mux.md — mkv_pgs_wrong_forced_label_is_cleared rationale. + // A wrong vendor forced label is CLEARED by the content, when a sibling track proves the + // authoring house sets `forced_on_flag`. #[test] fn mkv_pgs_wrong_forced_label_is_cleared_when_a_sibling_uses_the_flag() { use std::sync::{Arc, Mutex}; @@ -3550,9 +3509,8 @@ mod tests { ); } - // The guard that stops that from being reckless: when no disc track ever - // sets `forced_on_flag`, the vendor label must survive. See - // docs/mkv-mux.md — mkv_pgs_forced_label_survives rationale. + // The guard that stops that from being reckless: when no disc track ever sets + // `forced_on_flag`, the vendor label must survive. #[test] fn mkv_pgs_forced_label_survives_a_disc_that_never_sets_the_flag() { use std::sync::{Arc, Mutex}; @@ -4548,9 +4506,8 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidData); } - // A seam-plan clip list whose marks exclude every frame must fail with - // `SinkWroteNothing`, NOT `MkvInvalid`. See docs/mkv-mux.md — - // a_title_the_seam_plan_emptied audit finding. + // A seam-plan clip list whose marks exclude every frame must fail with `SinkWroteNothing`, + // NOT `MkvInvalid`. #[test] fn a_title_the_seam_plan_emptied_is_not_reported_as_a_skippable_stub() { let tracks = [make_video_track()]; @@ -4786,9 +4743,8 @@ mod tests { track: u64, } - // Parse EVERY BlockGroup in the output, in emission order — a presence - // check alone can't say WHICH blocks carry ReferenceBlock. See - // docs/mkv-mux.md — all_block_groups rationale. + // Parse EVERY BlockGroup in the output, in emission order — a presence check alone can't + // say WHICH blocks carry ReferenceBlock. fn all_block_groups(data: &[u8]) -> Vec<BlockGroupInfo> { let mut out = Vec::new(); for (body_start, body_size, _ts) in find_clusters(data) { @@ -5089,9 +5045,8 @@ mod tests { assert_eq!(monotonic_ts(Some(10), 100), 100); } - // SeekHead entries use fixed-width SeekID (4-byte binary, 0x84) and - // SeekPosition (8-byte uint, 0x88) so they can be back-patched in place. - // dolby_vision_config (dvcC) bit packing: see docs/mkv-mux.md. + // SeekHead entries use fixed-width SeekID (4-byte binary, 0x84) and SeekPosition (8-byte + // uint, 0x88) so they can be back-patched in place. dolby_vision_config (dvcC) bit packing: #[test] fn dolby_vision_config_packs_level_and_compat_id() { // profile 7, level 6 (0b00110), bl_compat_id 1. @@ -5697,9 +5652,8 @@ mod tests { ); } - // Parse the Tags master into (TagTrackUID, BPS) pairs, decoding the BPS - // TagString to a number rather than substring-matching the whole file. - // See docs/mkv-mux.md — parse_bps_tags decode rationale. + // Parse the Tags master into (TagTrackUID, BPS) pairs, decoding the BPS TagString to a + // number rather than substring-matching the whole file. fn parse_bps_tags(data: &[u8]) -> Vec<(u64, u64)> { let mut out = Vec::new(); let Some((_, tags_start, tags_size)) = segment_children(data) @@ -6300,9 +6254,8 @@ mod tests { body.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64) } - // The Chapters Seek entry, the only fixup whose offset comes from an - // Option, must be back-patched too. See docs/mkv-mux.md — - // seekhead_chapters_entry rationale. + // The Chapters Seek entry, the only fixup whose offset comes from an Option, must be + // back-patched too. #[test] fn seekhead_chapters_entry_resolves_to_the_chapters_element() { let tracks = [make_video_track()]; @@ -6333,9 +6286,8 @@ mod tests { ); } - // MkvTrack::video divides by disc-supplied numbers (frame-rate numerator, - // aspect denominator); zero must not panic. See docs/mkv-mux.md — - // video_track_survives_a_zero rationale. + // MkvTrack::video divides by disc-supplied numbers (frame-rate numerator, aspect + // denominator); zero must not panic. #[test] fn video_track_survives_a_zero_frame_rate_and_zero_aspect_denominator() { let base = VideoStream { @@ -6400,7 +6352,6 @@ mod tests { } // FlagDefault must never select a SECONDARY stream (DV EL, commentary). - // See docs/mkv-mux.md — secondary_streams_are_never_the_default rationale. #[test] fn secondary_streams_are_never_the_default_track() { let v = VideoStream { @@ -6443,9 +6394,8 @@ mod tests { ); } - // DVD subtitles are VobSub, Blu-ray are PGS; a lost DvdSub arm would - // silently mislabel every DVD track as PGS. See docs/mkv-mux.md — - // subtitle_codec_id_distinguishes_vobsub_from_pgs rationale. + // DVD subtitles are VobSub, Blu-ray are PGS; a lost DvdSub arm would silently mislabel + // every DVD track as PGS. #[test] fn subtitle_codec_id_distinguishes_vobsub_from_pgs() { let s = SubtitleStream { @@ -6472,9 +6422,8 @@ mod tests { ); } - // The HDR transfer override must fire even when the disc's color_space - // nibble disagrees (BT.709 + detected HDR10). See docs/mkv-mux.md — - // hdr_format_overrides_the_transfer rationale. + // The HDR transfer override must fire even when the disc's color_space nibble disagrees + // (BT.709 + detected HDR10). #[test] fn hdr_format_overrides_the_transfer_even_on_a_non_bt2020_color_space() { let v = VideoStream { @@ -6516,9 +6465,8 @@ mod tests { ); } - // mvc_decoder_config_record's length bounds (4 bytes min, 0xFFFF max) are - // exact; off by one loses the mvcC mapping. See docs/mkv-mux.md — - // mvc_decoder_config_record_boundaries rationale. + // mvc_decoder_config_record's length bounds (4 bytes min, 0xFFFF max) are exact; off by one + // loses the mvcC mapping. #[test] fn mvc_decoder_config_record_boundaries_are_inclusive_and_exact() { let sps4 = [0x6F, 0x64, 0x00, 0x1F]; diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index 83fc52cb..2c514565 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -11,9 +11,8 @@ use super::{WriteSeek, ebml}; /// `track_table` maps Matroska TrackNumbers onto `DiscTitle::streams` indices. type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>, i64, TrackTable)>; -// Skip `n` bytes on a forward-only reader (no Seek required). A short skip -// is a TRUNCATED element, reported as `MkvSourceInvalid` (not a silent `Ok`). -// See docs/mkvstream.md#skip-bytes for why the byte count is checked. +// Skip `n` bytes on a forward-only reader (no Seek required). A short skip is a TRUNCATED +// element, reported as `MkvSourceInvalid` (not a silent `Ok`). fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> { let skipped = io::copy(&mut r.take(n), &mut io::sink())?; if skipped != n { @@ -22,9 +21,8 @@ fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> { Ok(()) } -// ── Sanity caps for untrusted EBML element sizes: cast to `usize` for -// alloc/read, so every size is checked against a cap first (OOM/panic guard). -// See docs/mkvstream.md#ebml-size-caps. +// ── Sanity caps for untrusted EBML element sizes: cast to `usize` for alloc/read, so every +// size is checked against a cap first (OOM/panic guard). const MAX_BLOCK_SIZE: u64 = 64 * 1024 * 1024; // largest accepted SIMPLE_BLOCK payload /// Largest accepted CODEC_PRIVATE payload. hvcC/avcC/setup blobs are a @@ -599,9 +597,8 @@ impl MkvStream { } } -// Set a video track's `FieldOrder` from the MEASURED coding of the first coded -// picture — the parser's value, never a guess. See -// docs/mkvstream.md#apply-coding-to-track for the UNDETERMINED/logging cases. +// Set a video track's `FieldOrder` from the MEASURED coding of the first coded picture — the +// parser's value, never a guess. fn apply_coding_to_track( track: &mut MkvTrack, coding: Option<crate::mux::codec::PictureInfo>, @@ -1326,9 +1323,8 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result<ParsedTrack> { Ok((stream, tnum, codec_priv, default_dur, timing)) } -// Read-side map from Matroska TrackNumber to the index of the corresponding -// entry in `DiscTitle::streams` (TrackNumber is NOT guaranteed `1..=N`). See -// docs/mkvstream.md#track-table for why `TrackNumber - 1` is unsafe here. +// Read-side map from Matroska TrackNumber to the index of the corresponding entry in +// `DiscTitle::streams` (TrackNumber is NOT guaranteed `1..=N`). #[derive(Default)] struct TrackTable { /// Matroska TrackNumber of `DiscTitle::streams[i]`, indexed by `i`. @@ -1414,9 +1410,8 @@ fn lace_svint(d: &[u8]) -> Option<(i64, usize)> { Some(((v as i64) - bias, width)) } -// Split a LACED (Simple)Block's body into frame payloads per RFC 9559 §10.3. -// `None` on a malformed lacing header — caller MUST reject the block, not -// treat it as one frame. See docs/mkvstream.md#split-lacing. +// Split a LACED (Simple)Block's body into frame payloads per RFC 9559 §10.3. `None` on a +// malformed lacing header — caller MUST reject the block, not treat it as one frame. pub(crate) fn split_lacing(lacing: u8, body: &[u8]) -> Option<Vec<&[u8]>> { let (&count_minus_one, rest) = body.split_first()?; let n = count_minus_one as usize + 1; @@ -1494,9 +1489,8 @@ pub(crate) fn split_lacing(lacing: u8, body: &[u8]) -> Option<Vec<&[u8]>> { Some(out) } -// Parse a (Simple)Block payload into zero or more PesFrames: zero means -// SKIPPED (too short/track 0/undeclared TrackNumber), >1 means LACED, `Err` -// means a malformed lacing header. See docs/mkvstream.md#parse-block. +// Parse a (Simple)Block payload into zero or more PesFrames: zero means SKIPPED (too +// short/track 0/undeclared TrackNumber), >1 means LACED, `Err` means a malformed lacing header. fn parse_block( block: &[u8], cluster_ts_ticks: i64, @@ -2397,9 +2391,8 @@ mod tests { assert_eq!(frame.duration_ns, Some(40 * 1_000_000)); } - // A BlockGroup's `BlockAdditions` subtree (MVC dependent view) can't be - // carried by `PesFrame`, so read-back drops it — a LOSSY outcome that must - // be counted, never silent. See docs/mkvstream.md#block-additions-dropped-test. + // A BlockGroup's `BlockAdditions` subtree (MVC dependent view) can't be carried by + // `PesFrame`, so read-back drops it — a LOSSY outcome that must be counted, never silent. #[test] fn block_additions_dropped_on_read_back_is_counted_not_silent() { // The dependent-view payload: big enough that a byte count is unambiguous. @@ -2489,9 +2482,8 @@ mod tests { assert_eq!(stream.errors(), 1); } - // A BlockGroup carrying a ReferenceBlock is NOT a keyframe (the only - // non-keyframe signal a BlockGroup has); a past regression silently - // dropped MPEG-2 video. See docs/mkvstream.md#reference-block-keyframe-test. + // A BlockGroup carrying a ReferenceBlock is NOT a keyframe (the only non-keyframe signal a + // BlockGroup has); a past regression silently dropped MPEG-2 video. #[test] fn reference_block_marks_block_group_frame_as_non_keyframe() { // Same construction as the test above, plus a ReferenceBlock child. @@ -3143,9 +3135,8 @@ mod tests { assert!(stream.read().unwrap().is_none(), "clean EOF → None"); } - // A skipped element whose declared size runs PAST EOF is a truncated - // element, reported as `MkvSourceInvalid`, not a clean end of stream. - // See docs/mkvstream.md#skip-bytes for the regression this guards. + // A skipped element whose declared size runs PAST EOF is a truncated element, reported as + // `MkvSourceInvalid`, not a clean end of stream. #[test] fn a_skip_past_eof_is_an_error_not_a_clean_end_of_stream() { let mut cluster = Vec::new(); @@ -3293,9 +3284,9 @@ mod tests { out } - // EBML lacing (RFC 9559 §10.3.3): three frames of 3/4/5 octets must come out - // as THREE byte-exact frames, not one Block-verbatim frame with the lacing - // header as garbage payload. See docs/mkvstream.md#ebml-lacing-test. + // EBML lacing (RFC 9559 §10.3.3): three frames of 3/4/5 octets must come out as THREE + // byte-exact frames, not one Block-verbatim frame with the lacing header as garbage + // payload. #[test] fn ebml_laced_block_yields_every_frame_with_exact_payloads() { // size 3 → 0x83 (VINT, value 3). size delta 4-3 = +1 → unsigned 1 + bias @@ -3456,9 +3447,8 @@ mod tests { assert_eq!(frames[1].pts, 24_000_000, "spaced by the derived duration"); } - // TrackNumber gaps must not divide the stream index (`TrackNumber - 1` was - // a past regression); a sub-44100 SamplingFrequency must map to Unknown, - // not silently 48 kHz. See docs/mkvstream.md#track-number-gap-and-sub-44100-sampling-test. + // TrackNumber gaps must not divide the stream index (`TrackNumber - 1` was a past + // regression); a sub-44100 SamplingFrequency must map to Unknown, not silently 48 kHz. #[test] fn a_sub_44100_sampling_frequency_is_unknown_not_48k() { /// One TrackEntry body: an audio track with the given sampling frequency. @@ -3664,9 +3654,8 @@ mod tests { t } - // `finish()` turns a stream of frames into a FILE (activates + finalizes - // the muxer); proven by reading the output back through this crate's own - // reader. See docs/mkvstream.md#finish-produces-readable-mkv-test. + // `finish()` turns a stream of frames into a FILE (activates + finalizes the muxer); proven + // by reading the output back through this crate's own reader. #[test] fn finish_produces_a_readable_mkv_with_every_written_frame() { let out = SharedOut::new(); @@ -3730,9 +3719,8 @@ mod tests { ); } - // A title that produced NO frames must NOT finish successfully — MkvMuxer's - // zero-frame guard raises E6008, not a clusterless-but-"complete" MKV. - // See docs/mkvstream.md#finish-refuses-zero-frame-title-test. + // A title that produced NO frames must NOT finish successfully — MkvMuxer's zero-frame + // guard raises E6008, not a clusterless-but-"complete" MKV. #[test] fn finish_refuses_a_zero_frame_title_instead_of_reporting_success() { let out = SharedOut::new(); @@ -3754,9 +3742,8 @@ mod tests { ); } - // `headers_ready()` is unconditionally true for Matroska (Tracks precedes - // the first Cluster); the untrusted-size caps' magnitude, not just - // existence, is pinned here. See docs/mkvstream.md#headers-ready-and-untrusted-size-caps-test. + // `headers_ready()` is unconditionally true for Matroska (Tracks precedes the first + // Cluster); the untrusted-size caps' magnitude, not just existence, is pinned here. #[test] fn the_untrusted_size_caps_admit_real_discs_and_reject_hostile_ones() { // A UHD HEVC keyframe runs to a few MB — that has to get through. @@ -3913,9 +3900,8 @@ mod tests { } } - // Round-trip a real three-track title and check the TrackEntry metadata - // (language, name, forced flag, resolution, channels) survived — none of - // it was previously asserted. See docs/mkvstream.md#track-entry-metadata-round-trip-test. + // Round-trip a real three-track title and check the TrackEntry metadata (language, name, + // forced flag, resolution, channels) survived — none of it was previously asserted. #[test] fn track_entry_metadata_survives_a_write_read_round_trip() { let out = SharedOut::new(); @@ -4013,9 +3999,8 @@ mod tests { } } - // FlagDefault: only ONE video and ONE audio track may carry it; this - // de-duplication (not the muxer) is the only thing that enforces that. - // See docs/mkvstream.md#default-track-dedup-test. + // FlagDefault: only ONE video and ONE audio track may carry it; this de-duplication (not + // the muxer) is the only thing that enforces that. #[test] fn only_the_first_video_and_first_audio_track_are_default() { use crate::disc::{ @@ -4142,9 +4127,8 @@ mod tests { .map(|w| w[2] as u64) } - // A DVD declared 480i/576i but CODED progressive must ship - // `FlagInterlaced=progressive` — the measured bitstream overrides the - // declared resolution. See docs/mkvstream.md#progressive-on-declared-interlaced-test. + // A DVD declared 480i/576i but CODED progressive must ship `FlagInterlaced=progressive` — + // the measured bitstream overrides the declared resolution. #[test] fn a_progressive_picture_on_a_declared_interlaced_disc_ships_as_progressive() { use crate::disc::{ @@ -4423,9 +4407,8 @@ mod tests { ); } - // THE deferred-activation contract, end to end: the field order MEASURED - // from the first coded picture must reach the FILE, not just - // `apply_coding_to_track` in isolation. See docs/mkvstream.md#deferred-activation-end-to-end-test. + // THE deferred-activation contract, end to end: the field order MEASURED from the first + // coded picture must reach the FILE, not just `apply_coding_to_track` in isolation. #[test] fn the_measured_field_order_reaches_the_written_file() { use crate::disc::{ @@ -4659,9 +4642,8 @@ mod tests { assert_eq!(frames[0].track, 0, "0x4001 is TrackNumber 1 → stream 0"); } - // The `Video` master's child walk must consume EXACTLY its declared bytes, - // not over-run into a following field (fine for this crate's own writer, - // but breaks on foreign MKVs). See docs/mkvstream.md#video-master-walk-bounds-test. + // The `Video` master's child walk must consume EXACTLY its declared bytes, not over-run + // into a following field (fine for this crate's own writer, but breaks on foreign MKVs). #[test] fn the_video_master_walk_stops_at_its_own_end_not_inside_the_next_field() { let mut video = Vec::new(); diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 92c3fd0a..19844e11 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -38,9 +38,8 @@ pub(crate) mod demux_thread; // appropriate (`MkvStream`/`M2tsStream` from `lib.rs`), but the paths are not API. // Pre-0.13 these were `pub`, leaking EBML/TS/network/stdio internals. pub(crate) mod ebml; -/// `fvi://` sink — freemkv's native per-picture video index (see -/// `docs/FVI_FORMAT.md`). A write-only PES sink that emits one JSON-Lines record -/// per coded picture; reuses the pure-data [`videomap`] model. +/// `fvi://` sink — freemkv's native per-picture video index. A write-only PES sink that emits +/// one JSON-Lines record per coded picture; reuses the pure-data [`videomap`] model. pub(crate) mod fvi_sink; pub(crate) mod m2ts; /// FMKV metadata header (used by `M2tsStream` / `NetworkStream` / `StdioStream` @@ -72,8 +71,8 @@ pub(crate) mod stdio; pub(crate) mod timeline; pub(crate) mod ts; pub(crate) mod tsmux; -// Per-picture video index (FVI model) consumed by fvi_sink; pure data, serialization-independent. -// See docs/videomap.md — why the VideoMap accumulator is allow(dead_code) for now. +// Per-picture video index (FVI model) consumed by fvi_sink; pure data, +// serialization-independent. #[allow(dead_code)] pub(crate) mod videomap; diff --git a/src/mux/mp4/audio.rs b/src/mux/mp4/audio.rs index e6cd4f6a..7e0a221a 100644 --- a/src/mux/mp4/audio.rs +++ b/src/mux/mp4/audio.rs @@ -7,8 +7,6 @@ //! are derived from the first audio frame's bitstream (ISO/IEC 14496-12 //! amendments; ETSI TS 102 366 / 102 114). Codecs with no clean MP4 mapping //! (TrueHD, LPCM, bitmap subtitles) are excluded by the fit oracle. -// -// See docs/mp4-audio.md — DD+/AC-3 fallback rationale and the fit oracle detail. use super::boxes::bx; use crate::disc::Codec; @@ -20,7 +18,6 @@ const EAC3_REDUCED_RATES: [u32; 4] = [24_000, 22_050, 16_000, 48_000]; /// Base channel count per `acmod` (A/52 Table 5.8), before the LFE. const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5]; // Lowest `bsid` that is Annex-E (E-AC-3); 8 is AC-3, 9/10 are Annex D. -// See docs/mp4-audio.md — why parser and chooser share this one constant. const EAC3_MIN_BSID: u8 = 11; /// Width of the `dec3` `data_rate` field in bits (ETSI TS 102 366 Annex F.6.1). const DEC3_DATA_RATE_BITS: u32 = 13; @@ -40,9 +37,8 @@ impl<'a> BitReader<'a> { fn skip(&mut self, n: usize) { self.bit += n; } - // Read `n` bits (n <= 32). Returns 0 past end of data (callers pre-check len). - // `|` (not `^`) is intentional here and in the `push` closures below. - // See docs/mp4-audio.md — BitReader::read. + // Read `n` bits (n <= 32). Returns 0 past end of data (callers pre-check len). `|` (not + // `^`) is intentional here and in the `push` closures below. fn read(&mut self, n: usize) -> u32 { let mut v = 0u32; for _ in 0..n { @@ -195,7 +191,7 @@ pub(super) fn dac3_box(c: &DolbyConfig) -> Vec<u8> { /// The `dec3` config box (ETSI TS 102 366 Annex G.3): single independent /// substream, no dependents. data_rate(13) num_ind_sub(3) fscod(2) bsid(5) /// reserved(1) asvc(1) bsmod(3) acmod(3) lfeon(1) reserved(3) num_dep_sub(4) reserved(1). -// data_rate must be non-zero; only parse_eac3 computes one (see docs/mp4-audio.md). +// data_rate must be non-zero; only parse_eac3 computes one. pub(super) fn dec3_box(c: &DolbyConfig) -> Vec<u8> { let mut v: u64 = 0; let mut push = |val: u64, bits: u32| v = (v << bits) | (val & ((1u64 << bits) - 1)); @@ -253,8 +249,8 @@ const DTS_SFREQ: [u32; 16] = [ 48_000, 8_000, 16_000, 32_000, 48_000, 48_000, 11_025, 22_050, 44_100, 48_000, 48_000, 12_000, 24_000, 48_000, 96_000, 192_000, ]; -// DTS core base channel count per AMODE (ETSI TS 102 114 §5.3.1); matches the -// decodability gate's DTS_AMODE_COUNT in dts.rs. See docs/mp4-audio.md. +// DTS core base channel count per AMODE (ETSI TS 102 114 §5.3.1); matches the decodability +// gate's DTS_AMODE_COUNT in dts.rs. const DTS_AMODE_CH: [u8; 16] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5, 6, 6, 6, 7, 8, 8]; /// Decoded DTS core parameters needed for the `ddts` box. @@ -319,9 +315,8 @@ fn parse_dts(frame: &[u8]) -> Option<DtsConfig> { }) } -// `ddts` ChannelLayout speaker mask per core AMODE (bit0=C, bit1=L/R, ... -// bit15=Lhr/Rhr; paired bits = two speakers). Must stay consistent with -// DTS_AMODE_CH — see docs/mp4-audio.md for the invariant and history. +// `ddts` ChannelLayout speaker mask per core AMODE (bit0=C, bit1=L/R,... bit15=Lhr/Rhr; paired +// bits = two speakers). Must stay consistent with DTS_AMODE_CH. const DTS_AMODE_LAYOUT: [u16; 16] = [ 0x0001, // 0 A → C 0x0002, // 1 A + B (dual mono) → L/R @@ -861,7 +856,6 @@ mod tests { /// AC-3 syncframe with every BSI field distinct: fscod=1 (44.1 kHz), /// frmsizecod=37 (bit_rate_code 18), bsid=8, bsmod=5, acmod=6 (2/2), /// lfeon=1 -> 4.1 = 5 channels. - // Byte layout and the acmod-6 cmixlev/surmixlev derivation: docs/mp4-audio.md. fn ac3_frame_distinct() -> Vec<u8> { vec![0x0B, 0x77, 0x00, 0x00, 0x65, 0x45, 0xC4, 0x00] } @@ -975,14 +969,12 @@ mod tests { /// Annex-E syncframe with every field distinct: frmsiz=0x123 (292 words /// -> 584 bytes), fscod=1 (44.1 kHz), numblkscod=0 (1 block -> 256 samples), /// acmod=5 (3/1), lfeon=1 -> 5 channels, bsid=16. - // Byte-by-byte field layout: docs/mp4-audio.md. fn eac3_frame_distinct() -> Vec<u8> { vec![0x0B, 0x77, 0x01, 0x23, 0x4B, 0x80, 0x00] } /// Annex-E syncframe with fscod=3 — the reduced-sample-rate path, which no /// other fixture reaches. fscod2=2 -> 16 kHz, 6 blocks; acmod=2, lfeon=0. - // Byte layout and minimum-length note: docs/mp4-audio.md. fn eac3_frame_fscod3() -> Vec<u8> { vec![0x0B, 0x77, 0x00, 0x0F, 0xE4, 0x80] } @@ -1079,7 +1071,6 @@ mod tests { /// A DTS core whose split fields all have their high parts set, so a lost /// high bit is visible: NBLKS=79 -> 2560 samples; FSIZE=0x3000 -> core 12289. - // Bit-position breakdown: docs/mp4-audio.md. fn dts_frame_high_bits_set() -> Vec<u8> { vec![ 0x7F, 0xFE, 0x80, 0x01, 0x01, 0x3F, 0x00, 0x02, 0x74, 0x00, 0x0A, 0x00, diff --git a/src/mux/mp4/mod.rs b/src/mux/mp4/mod.rs index d73435b8..61dda7c1 100644 --- a/src/mux/mp4/mod.rs +++ b/src/mux/mp4/mod.rs @@ -7,8 +7,6 @@ //! //! One video track plus every audio track with a clean MP4 mapping; codecs MP4 //! can't carry are **excluded, never silently dropped** — [`fit_report`] says why. -//! -//! See docs/mp4-mux.md#module-overview for full rationale and references. use crate::disc::{Codec, DiscTitle, Stream as DiscStream}; use crate::pes::{PesFrame, Stream}; @@ -50,9 +48,8 @@ fn round_up_grain(x: u64) -> u64 { x.div_ceil(RESERVE_GRAIN).saturating_mul(RESERVE_GRAIN) } -// Whether leftover reserved-hole slack can be closed: 0 needs no `free` box, -// 8+ bytes holds one (8-byte box header). 1-7 bytes fits no box, so finish() -// falls back to moov-at-end. See docs/mp4-mux.md#faststart_fits. +// Whether leftover reserved-hole slack can be closed: 0 needs no `free` box, 8+ bytes holds one +// (8-byte box header). 1-7 bytes fits no box, so finish() falls back to moov-at-end. fn faststart_fits(gap: u64) -> bool { gap == 0 || gap >= 8 } @@ -376,9 +373,8 @@ impl<W: Write + Seek> Mp4Sink<W> { /// into `skipped` with a post-mux reason ([`Mp4SkipReason::NoSamples`], /// [`Mp4SkipReason::UndescribableAudio`]). /// - /// Call this after `finish()`, not the pre-mux plan, before reporting what - /// was written: the plan is only a prediction and can still list a stream - /// `finish()` had to drop. See docs/mp4-mux.md#mp4sinkfinal_report. + /// Call this after `finish()`, not the pre-mux plan, before reporting what was written: the + /// plan is only a prediction and can still list a stream `finish()` had to drop. pub fn final_report(&self) -> Mp4FitReport { let mut included = self.plan.included.clone(); included.retain(|i| !self.dropped.iter().any(|(d, _)| d == i)); @@ -528,9 +524,8 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> { &self.title } - // The streams finish() had to drop — see final_report() for reasons. - // Folded into MuxOutcome::undelivered_streams so callers learn this - // programmatically. See docs/mp4-mux.md#mp4sinkundelivered_streams. + // The streams finish() had to drop — see final_report() for reasons. Folded into + // MuxOutcome::undelivered_streams so callers learn this programmatically. fn undelivered_streams(&self) -> Vec<usize> { self.dropped.iter().map(|&(i, _)| i).collect() } @@ -656,9 +651,9 @@ const STD_RATES: &[(u32, u32, f64)] = &[ (60, 1, 60.0), ]; -// How far the measured rate may sit from a STD_RATES entry and still snap to -// it; nearest-wins, not first-wins (see STD_RATES). Two `<`→`<=` mutants on -// the snapping loop are believed unreachable — see docs/mp4-mux.md#rate_tolerance_fps. +// How far the measured rate may sit from a STD_RATES entry and still snap to it; nearest-wins, +// not first-wins (see STD_RATES). Two `<`→`<=` mutants on the snapping loop are believed +// unreachable. const RATE_TOLERANCE_FPS: f64 = 0.5; /// Detect the constant frame rate from the median presentation delta, snapping @@ -822,8 +817,7 @@ fn build_dinf() -> Vec<u8> { } // Colour signalling for `colr` (nclx, ISO/IEC 14496-12 §12.1.5). Must use -// crate::mux::mkv::cicp_for_video, the one resolver every sink shares — see -// docs/mp4-mux.md#video_colr for the past drift this guards against. +// crate::mux::mkv::cicp_for_video, the one resolver every sink shares. fn video_colr(stream: &DiscStream) -> Option<(u16, u16, u16, bool)> { let DiscStream::Video(v) = stream else { return None; @@ -1103,9 +1097,8 @@ mod tests { assert_eq!(r.included, vec![1], "only the AC-3 audio is carried"); } - // A video track whose resolution never resolved must FAIL the mux, not be - // written as a 0x0 track (ISO/IEC 14496-12 mandates width/height). See - // docs/mp4-mux.md#test-a_video_track_with_no_resolved_resolution_is_an_error_not_a_zero_sized_track. + // A video track whose resolution never resolved must FAIL the mux, not be written as a 0x0 + // track (ISO/IEC 14496-12 mandates width/height). #[test] fn a_video_track_with_no_resolved_resolution_is_an_error_not_a_zero_sized_track() { let DiscStream::Video(mut v) = hevc_video() else { @@ -1220,9 +1213,8 @@ mod tests { None } - // An audio track whose frames never yield a parseable sample entry must be - // dropped from moov, not written as an stsd around an empty entry, while - // the frames still reach mdat. See docs/mp4-mux.md#test-audio_track_with_no_parseable_sample_entry_is_dropped_not_emitted_empty. + // An audio track whose frames never yield a parseable sample entry must be dropped from + // moov, not written as an stsd around an empty entry, while the frames still reach mdat. #[test] fn audio_track_with_no_parseable_sample_entry_is_dropped_not_emitted_empty() { let t = title( @@ -1268,8 +1260,7 @@ mod tests { } // Dropping the undescribable audio track keeps the export succeeding, but - // final_report()/undelivered_streams() must stop claiming that stream - // afterward. See docs/mp4-mux.md#test-dropped_audio_track_is_reported_not_just_logged. + // final_report()/undelivered_streams() must stop claiming that stream afterward. #[test] fn dropped_audio_track_is_reported_not_just_logged() { let t = title( @@ -1312,9 +1303,8 @@ mod tests { ); } - // mvhd.next_track_id must EXCEED every track_ID in the file (ISO/IEC - // 14496-12 §8.2.2), not be derived from the retained track count. See - // docs/mp4-mux.md#test-mvhd_next_track_id_exceeds_every_retained_track_id. + // mvhd.next_track_id must EXCEED every track_ID in the file (ISO/IEC 14496-12 §8.2.2), not + // be derived from the retained track count. #[test] fn mvhd_next_track_id_exceeds_every_retained_track_id() { let t = title( @@ -1616,8 +1606,7 @@ mod tests { // ── pack_language ──────────────────────────────────────────────────────── - // Bit-twiddles pinned against hand-computed values; three mutants proven - // equivalent. See docs/mp4-mux.md#test-pack_language_packs_three_lowercase_letters_into_15_bits. + // Bit-twiddles pinned against hand-computed values; three mutants proven equivalent. #[test] fn pack_language_packs_three_lowercase_letters_into_15_bits() { // "bcd": b=2, c=3, d=4 → (2<<10)|(3<<5)|4 = 2048+96+4 = 0x0864. @@ -1650,9 +1639,9 @@ mod tests { assert_eq!(RESERVE_FLOOR, 8 * 1024 * 1024); } - // estimate_reserve's per-stream fps uses the stream's own rate only when - // n > 0 && d > 0, else a flat 24.0 fallback; a film-rate (23.976) fixture - // is the one that can tell the two apart. See docs/mp4-mux.md#test-estimate_reserve_uses_the_streams_own_fps_not_the_24fps_fallback. + // estimate_reserve's per-stream fps uses the stream's own rate only when n > 0 && d > 0, + // else a flat 24.0 fallback; a film-rate (23.976) fixture is the one that can tell the two + // apart. #[test] fn estimate_reserve_uses_the_streams_own_fps_not_the_24fps_fallback() { let mut t = title(vec![hevc_video()], vec![]); @@ -1664,9 +1653,8 @@ mod tests { ); } - // Complementary case: FrameRate::Unknown (n=0) must take the 24.0 - // fallback branch, not compute 0/1 = 0.0 fps. See - // docs/mp4-mux.md#test-estimate_reserve_unknown_frame_rate_falls_back_to_24fps_not_zero. + // Complementary case: FrameRate::Unknown (n=0) must take the 24.0 fallback branch, not + // compute 0/1 = 0.0 fps. #[test] fn estimate_reserve_unknown_frame_rate_falls_back_to_24fps_not_zero() { let mut vc1 = match hevc_video() { @@ -1686,9 +1674,8 @@ mod tests { ); } - // estimate_reserve models DTS at 512 samples/AU, a third of the 1536 - // (E-)AC-3 default; losing the DTS arm under-reserves DTS titles 3x. See - // docs/mp4-mux.md#test-estimate_reserve_models_dts_at_512_samples_per_frame_not_1536. + // estimate_reserve models DTS at 512 samples/AU, a third of the 1536 (E-)AC-3 default; + // losing the DTS arm under-reserves DTS titles 3x. #[test] fn estimate_reserve_models_dts_at_512_samples_per_frame_not_1536() { let t_dts = { @@ -1792,9 +1779,9 @@ mod tests { assert_eq!(timing.ctts(), vec![10, 3, 16]); } - // tkhd.duration = secs * MOVIE_TIMESCALE, a separate 90 kHz computation - // from mdhd's media-timescale duration (ISO/IEC 14496-12 §8.3.2). Read - // straight from emitted tkhd bytes. See docs/mp4-mux.md#test-tkhd_duration_is_seconds_times_movie_timescale_for_both_media_types. + // tkhd.duration = secs * MOVIE_TIMESCALE, a separate 90 kHz computation from mdhd's + // media-timescale duration (ISO/IEC 14496-12 §8.3.2). Read straight from emitted tkhd + // bytes. #[test] fn tkhd_duration_is_seconds_times_movie_timescale_for_both_media_types() { fn tkhd_duration(trak: &[u8]) -> u64 { @@ -1876,9 +1863,8 @@ mod tests { ); } - // audio_sample_durations' per-sample ticks are ns*ts/NS; inter-sample - // delta is ticks(next)-ticks(prev), last duration repeated for the - // trailing sample. See docs/mp4-mux.md#test-audio_sample_durations_computes_exact_tick_deltas_and_repeats_the_last. + // audio_sample_durations' per-sample ticks are ns*ts/NS; inter-sample delta is + // ticks(next)-ticks(prev), last duration repeated for the trailing sample. #[test] fn audio_sample_durations_computes_exact_tick_deltas_and_repeats_the_last() { let samples = vec![ @@ -1909,9 +1895,8 @@ mod tests { ); } - // Single-sample fallback pushes timescale/30, guarded by - // !samples.is_empty(); windows(2) yields nothing so it's the only source - // of a duration. See docs/mp4-mux.md#test-audio_sample_durations_single_sample_uses_timescale_over_30_fallback. + // Single-sample fallback pushes timescale/30, guarded by !samples.is_empty(); windows(2) + // yields nothing so it's the only source of a duration. #[test] fn audio_sample_durations_single_sample_uses_timescale_over_30_fallback() { let samples = vec![Sample { @@ -1949,9 +1934,8 @@ mod tests { } } - // Fewer than 2 samples can't measure a delta: fixed 90 kHz/3003 fallback. - // Exactly 2 samples is the boundary, not just "fewer than 2". See - // docs/mp4-mux.md#test-detect_rate_needs_at_least_two_samples_not_more. + // Fewer than 2 samples can't measure a delta: fixed 90 kHz/3003 fallback. Exactly 2 samples + // is the boundary, not just "fewer than 2". #[test] fn detect_rate_needs_at_least_two_samples_not_more() { assert_eq!(detect_rate(&[]), (90_000, 3_003)); @@ -1985,9 +1969,8 @@ mod tests { ); } - // Only positive deltas are considered (filter(|&d| d > 0)); letting zero - // deltas through shifts the median index and collapses fps to infinity. - // See docs/mp4-mux.md#test-detect_rate_filters_zero_deltas_not_just_negative_ones. + // Only positive deltas are considered (filter(|&d| d > 0)); letting zero deltas through + // shifts the median index and collapses fps to infinity. #[test] fn detect_rate_filters_zero_deltas_not_just_negative_ones() { let samples: Vec<Sample> = vec![0, 0, 0, 40_000_000] @@ -2007,9 +1990,8 @@ mod tests { ); } - // A rate with no nearby STD_RATES entry takes the fallback: timescale - // 90_000, duration = (median * 90_000) / NS. 5 fps gives an exact - // multiple. See docs/mp4-mux.md#test-detect_rate_fallback_duration_is_median_times_90khz_over_ns. + // A rate with no nearby STD_RATES entry takes the fallback: timescale 90_000, duration = + // (median * 90_000) / NS. 5 fps gives an exact multiple. #[test] fn detect_rate_fallback_duration_is_median_times_90khz_over_ns() { let samples: Vec<Sample> = (0..10) diff --git a/src/mux/mp4/read.rs b/src/mux/mp4/read.rs index 323f0748..a376ee9e 100644 --- a/src/mux/mp4/read.rs +++ b/src/mux/mp4/read.rs @@ -21,20 +21,19 @@ use std::path::Path; const NS: i128 = 1_000_000_000; -// Upper bound on the number of tracks — the per-track PID (`0x1011 + -// track_idx`) overflows u16 past ~61k. See docs/mp4-read.md — MAX_TRACKS. +// Upper bound on the number of tracks — the per-track PID (`0x1011 + track_idx`) overflows u16 +// past ~61k. const MAX_TRACKS: usize = 512; -// Upper bound on a track's decoded sample count; caps a crafted box's -// allocation. See docs/mp4-read.md — MAX_SAMPLE_COUNT. +// Upper bound on a track's decoded sample count; caps a crafted box's allocation. const MAX_SAMPLE_COUNT: usize = 1 << 24; -// Smallest FILE bytes one indexed sample is assumed to occupy (divisor for -// `from_reader`'s sample budget). See docs/mp4-read.md — MIN_FILE_BYTES_PER_SAMPLE. +// Smallest FILE bytes one indexed sample is assumed to occupy (divisor for `from_reader`'s +// sample budget). const MIN_FILE_BYTES_PER_SAMPLE: u64 = 16; -// Ceiling on a single allocation sized from an untrusted MP4 field, since a -// sparse file can inflate `file_len` cheaply. See docs/mp4-read.md — MAX_ALLOC_BYTES. +// Ceiling on a single allocation sized from an untrusted MP4 field, since a sparse file can +// inflate `file_len` cheaply. const MAX_ALLOC_BYTES: u64 = 256 << 20; // 256 MiB /// One sample's location + timing in the emission plan. @@ -561,9 +560,8 @@ fn parse_elst(b: &[u8]) -> Vec<EditListEntry> { out } -// Presentation-time offset an edit list imposes on a track's samples, in -// MEDIA timescale ticks: (sum of leading empty segment_durations) minus the -// first non-empty edit's media_time. See docs/mp4-read.md — elst_offset_ticks. +// Presentation-time offset an edit list imposes on a track's samples, in MEDIA timescale ticks: +// (sum of leading empty segment_durations) minus the first non-empty edit's media_time. fn elst_offset_ticks( entries: &[EditListEntry], movie_timescale: Option<u32>, @@ -1013,9 +1011,9 @@ fn parse_stss(b: &[u8]) -> std::collections::HashSet<u32> { mod tests { use super::*; - // `Read + Seek` backed by a small crafted prefix + endless zeros, reporting - // `len` bytes on `seek(End)` — exercises MAX_ALLOC_BYTES-scale reads - // without a real multi-GiB source allocation. See docs/mp4-read.md — FakeBigReader. + // `Read + Seek` backed by a small crafted prefix + endless zeros, reporting `len` bytes on + // `seek(End)` — exercises MAX_ALLOC_BYTES-scale reads without a real multi-GiB source + // allocation. struct FakeBigReader { prefix: Vec<u8>, pos: u64, @@ -1070,9 +1068,8 @@ mod tests { ); } - // Positive-path test for `parse_stss`: two distinct entries in a buffer - // sized to exactly fit them, catching offset/stride arithmetic mutants. - // See docs/mp4-read.md — parse_stss_reads_two_distinct_entries. + // Positive-path test for `parse_stss`: two distinct entries in a buffer sized to exactly + // fit them, catching offset/stride arithmetic mutants. #[test] fn parse_stss_reads_two_distinct_entries_from_their_own_offsets() { let mut b = vec![0u8, 0, 0, 0]; // version+flags @@ -1088,9 +1085,8 @@ mod tests { ); } - // A declared `count` larger than the box can hold must be bounded by the - // per-entry guard, not trusted — same contract as stco/stsc/stts. - // See docs/mp4-read.md — parse_stss_count_lie. + // A declared `count` larger than the box can hold must be bounded by the per-entry guard, + // not trusted — same contract as stco/stsc/stts. #[test] fn parse_stss_count_lie_is_bounded_by_the_box_not_trusted() { let mut b = vec![0u8, 0, 0, 0]; @@ -1141,9 +1137,8 @@ mod tests { assert_eq!(offs[1], u64::MAX, "(MAX-5)+10 saturates to MAX"); } - // `sidx` must advance once PER SAMPLE PLACED; a chunk of only two samples - // can't expose a stuck index, so this uses three distinctly-sized samples. - // See docs/mp4-read.md — sample_offsets_advances_through_distinct_sizes. + // `sidx` must advance once PER SAMPLE PLACED; a chunk of only two samples can't expose a + // stuck index, so this uses three distinctly-sized samples. #[test] fn sample_offsets_advances_through_distinct_sizes_within_one_chunk() { let sizes = [10u32, 20, 30]; @@ -1209,9 +1204,8 @@ mod tests { ); } - // `Stream::read`'s own `s.size as u64 > MAX_ALLOC_BYTES` cap — a separate - // call site from `read_moov`'s. Exactly-the-cap must still be allowed. - // See docs/mp4-read.md — stream_read_allows_a_sample_of_exactly_max_alloc_bytes. + // `Stream::read`'s own `s.size as u64 > MAX_ALLOC_BYTES` cap — a separate call site from + // `read_moov`'s. Exactly-the-cap must still be allowed. #[test] fn stream_read_allows_a_sample_of_exactly_max_alloc_bytes() { use crate::disc::DiscTitle; @@ -1242,8 +1236,8 @@ mod tests { assert_eq!(frame.data.len() as u64, MAX_ALLOC_BYTES); } - // The same cap's other edge: one byte OVER it must still be rejected, - // not just values exactly at the cap. See docs/mp4-read.md — stream_read_rejects. + // The same cap's other edge: one byte OVER it must still be rejected, not just values + // exactly at the cap. #[test] fn stream_read_rejects_a_sample_one_byte_over_max_alloc_bytes() { use crate::disc::DiscTitle; @@ -1412,9 +1406,8 @@ mod tests { assert_eq!(auds[0].1, ac3.len()); } - // `stts` run-length expansion (ISO/IEC 14496-12 §8.6.1.2) must expand - // `(sample_count, sample_delta)` runs to one delta PER SAMPLE, in order. - // See docs/mp4-read.md — stts_expands_runs_to_per_sample_deltas. + // `stts` run-length expansion (ISO/IEC 14496-12 §8.6.1.2) must expand `(sample_count, + // sample_delta)` runs to one delta PER SAMPLE, in order. #[test] fn stts_expands_runs_to_per_sample_deltas_in_order() { // Three runs with DISTINCT deltas/lengths, so a parser that drops a run, @@ -1567,9 +1560,8 @@ mod tests { ); } - // A track's declared duration must be its real length: `mdhd.duration` - // in MEDIA timescale, `mvhd.duration` in movie timescale (§8.2.2, §8.4.2). - // See docs/mp4-read.md — declared_track_duration. + // A track's declared duration must be its real length: `mdhd.duration` in MEDIA timescale, + // `mvhd.duration` in movie timescale (§8.2.2, §8.4.2). #[test] fn declared_track_duration_equals_frame_count_times_frame_duration() { use crate::disc::{ @@ -1651,9 +1643,8 @@ mod tests { ); } - // The `moov` tree must carry the boxes ISO/IEC 14496-12 makes mandatory - // for a playable track. Lives here (not the writer's tests) so assertions - // go through `find_box`. See docs/mp4-read.md — moov_tree_carries_mandatory_boxes. + // The `moov` tree must carry the boxes ISO/IEC 14496-12 makes mandatory for a playable + // track. Lives here (not the writer's tests) so assertions go through `find_box`. #[test] // Underscores mark BITFIELD boundaries in the bitstream header being built // (e.g. 5-bit then 3-bit field), not thousands-style digit groups. @@ -1869,9 +1860,8 @@ mod tests { assert_eq!(parse_stsc(&stsc), vec![(1, 7)]); } - // Every one of these five table parsers must reject every buffer length - // below the 8-byte version+flags+count header, not just exactly `< 8`. - // See docs/mp4-read.md — table_parsers_reject_every_length. + // Every one of these five table parsers must reject every buffer length below the 8-byte + // version+flags+count header, not just exactly `< 8`. #[test] fn table_parsers_reject_every_length_up_to_the_header_size() { for len in 0..=8 { @@ -1897,9 +1887,8 @@ mod tests { } } - // `co64`'s 8-byte offsets decode byte-by-byte, a separate path from the - // 32-bit `stco`/`be32` case; every byte here is distinct to catch index slips. - // See docs/mp4-read.md — parse_stco_co64_reads_each_byte. + // `co64`'s 8-byte offsets decode byte-by-byte, a separate path from the 32-bit + // `stco`/`be32` case; every byte here is distinct to catch index slips. #[test] fn parse_stco_co64_reads_each_byte_from_its_own_offset() { let mut b = vec![0u8; 4]; // version+flags (unused by this parser) @@ -1933,8 +1922,8 @@ mod tests { assert!(parse_stsd(&b).is_none()); } - // Every buffer shorter than the 8-byte header must return `None`, never - // fall through to `&b[8..]` and panic. See docs/mp4-read.md — parse_stsd_rejects. + // Every buffer shorter than the 8-byte header must return `None`, never fall through to + // `&b[8..]` and panic. #[test] fn parse_stsd_rejects_every_length_below_the_header_size() { for len in 0..8 { @@ -1976,9 +1965,8 @@ mod tests { assert_eq!(parse_esds_asc(&esds[..12]), None); } - // The final guard `asc_len == 0 || end > b.len()` is two independent - // rejection reasons, not one condition needing both; pins each half. - // See docs/mp4-read.md — parse_esds_asc_boundary_checks. + // The final guard `asc_len == 0 || end > b.len()` is two independent rejection reasons, not + // one condition needing both; pins each half. #[test] fn parse_esds_asc_boundary_checks_are_independent() { // Shared prefix through the DecoderSpecificInfo tag (byte 24 = 0x05); @@ -2109,9 +2097,8 @@ mod tests { assert!(read_moov(&mut Cursor::new(b)).is_err()); } - // `box_size == header_len` (8) is the legal empty-box boundary: an empty - // `moov` must parse to `Ok(vec![])`, not be rejected by `<`→`<=`/`==`. - // See docs/mp4-read.md — read_moov_exactly_header_sized. + // `box_size == header_len` (8) is the legal empty-box boundary: an empty `moov` must parse + // to `Ok(vec![])`, not be rejected by `<`→`<=`/`==`. #[test] fn read_moov_exactly_header_sized_is_a_valid_empty_box() { use std::io::Cursor; @@ -2125,9 +2112,8 @@ mod tests { ); } - // The forward-progress/EOF guard is an `||` of two clauses; an input - // where exactly one is true must still be rejected as `Mp4Invalid`, not - // fall through to a bare `UnexpectedEof`. See docs/mp4-read.md — read_moov_rejects_overrun. + // The forward-progress/EOF guard is an `||` of two clauses; an input where exactly one is + // true must still be rejected as `Mp4Invalid`, not fall through to a bare `UnexpectedEof`. #[test] fn read_moov_rejects_a_box_that_overruns_the_file_via_the_or_not_and_guard() { use std::io::Cursor; @@ -2145,8 +2131,8 @@ mod tests { ); } - // `payload_len > MAX_ALLOC_BYTES` is a separate cap from the EOF check; - // a payload of exactly the cap must be allowed. See docs/mp4-read.md — read_moov_allows_exactly_max_alloc. + // `payload_len > MAX_ALLOC_BYTES` is a separate cap from the EOF check; a payload of + // exactly the cap must be allowed. #[test] fn read_moov_allows_a_payload_of_exactly_max_alloc_bytes() { let box_size = MAX_ALLOC_BYTES as u32 + 8; // header + exactly the cap @@ -2365,9 +2351,8 @@ mod tests { mp4_box(b"trak", &payload) } - // Regression (silent A/V desync): a non-empty edit with `media_time = - // 1024` (encoder delay) must shift the track's presentation, not be ignored. - // See docs/mp4-read.md — edit_list_media_time_shifts_the_presentation_timeline. + // Regression (silent A/V desync): a non-empty edit with `media_time = 1024` (encoder delay) + // must shift the track's presentation, not be ignored. #[test] fn edit_list_media_time_shifts_the_presentation_timeline() { use std::io::Cursor; @@ -2467,9 +2452,8 @@ mod tests { ); } - // Every byte of a version-1 entry's fields must come from its own - // offset, not a neighbour's; every byte here is distinct and nonzero. - // See docs/mp4-read.md — parse_elst_v1_entry_bytes. + // Every byte of a version-1 entry's fields must come from its own offset, not a + // neighbour's; every byte here is distinct and nonzero. #[test] fn parse_elst_v1_entry_bytes_come_from_their_own_offsets() { let mut v1 = vec![1u8, 0, 0, 0]; // version 1 + flags @@ -2484,9 +2468,9 @@ mod tests { ); } - // At `empty_movie_ticks == 0` the function must short-circuit before a - // `movie_timescale == Some(0)` is ever used as a divisor, independent of - // what the one real caller happens to pass. See docs/mp4-read.md — elst_offset_ticks_stays_safe. + // At `empty_movie_ticks == 0` the function must short-circuit before a `movie_timescale == + // Some(0)` is ever used as a divisor, independent of what the one real caller happens to + // pass. #[test] fn elst_offset_ticks_stays_safe_at_zero_empty_ticks_even_with_a_zero_movie_timescale() { assert_eq!( @@ -2519,9 +2503,8 @@ mod tests { ); } - // A minimal audio `trak` whose fixed-size `stsz` LIES about its sample - // count (`u32::MAX`); stsc/stts are wide enough to place whatever count - // survives the budget. See docs/mp4-read.md — audio_trak_hostile_count. + // A minimal audio `trak` whose fixed-size `stsz` LIES about its sample count (`u32::MAX`); + // stsc/stts are wide enough to place whatever count survives the budget. fn audio_trak_hostile_count() -> Vec<u8> { let mdhd = { let mut p = vec![0u8; 24]; @@ -2618,9 +2601,8 @@ mod tests { ); } - // The RAM amplification the byte-per-sample budget bounds: a crafted - // `stsz` claiming u32::MAX samples must not force an eager multi-hundred-MB - // index. See docs/mp4-read.md — sample_index_ram_is_bounded. + // The RAM amplification the byte-per-sample budget bounds: a crafted `stsz` claiming + // u32::MAX samples must not force an eager multi-hundred-MB index. #[test] fn sample_index_ram_is_bounded_by_a_multiple_of_the_file() { use std::io::Cursor; @@ -2722,9 +2704,8 @@ mod tests { mp4_box(b"trak", &mp4_box(b"mdia", &mdia)) } - // A track with samples (`stsz`) but no chunk-offset table (`stco`/`co64`) - // must be DROPPED, not indexed with offsets near file byte 0. - // See docs/mp4-read.md — missing_stco_drops_track. + // A track with samples (`stsz`) but no chunk-offset table (`stco`/`co64`) must be DROPPED, + // not indexed with offsets near file byte 0. #[test] fn missing_stco_drops_track_all_dropped_is_invalid() { use std::io::Cursor; @@ -2736,8 +2717,7 @@ mod tests { ); } - // A track with samples + chunk offsets but no sample-to-chunk map - // (`stsc`) must be DROPPED. See docs/mp4-read.md — missing_stsc_drops_track. + // A track with samples + chunk offsets but no sample-to-chunk map (`stsc`) must be DROPPED. #[test] fn missing_stsc_drops_track_all_dropped_is_invalid() { use std::io::Cursor; @@ -2749,8 +2729,8 @@ mod tests { ); } - // A track with samples but no time-to-sample table (`stts`, mandatory - // per §8.6.1) must be DROPPED. See docs/mp4-read.md — missing_stts_drops_track. + // A track with samples but no time-to-sample table (`stts`, mandatory per §8.6.1) must be + // DROPPED. #[test] fn missing_stts_drops_track_all_dropped_is_invalid() { use std::io::Cursor; @@ -2762,9 +2742,8 @@ mod tests { ); } - // An `stsc` that passes the non-empty guard but places fewer samples than - // `stsz` declares must still drop the track, not invent positions for the tail. - // See docs/mp4-read.md — stsc_placing_fewer_samples. + // An `stsc` that passes the non-empty guard but places fewer samples than `stsz` declares + // must still drop the track, not invent positions for the tail. #[test] fn stsc_placing_fewer_samples_than_stsz_drops_the_track() { use std::io::Cursor; @@ -2796,9 +2775,8 @@ mod tests { ); } - // A SHORT `stts` covering fewer samples than `stsz` declares must drop the - // track just like an absent one, not collapse the tail onto dur=0. - // See docs/mp4-read.md — short_stts_drops_the_track. + // A SHORT `stts` covering fewer samples than `stsz` declares must drop the track just like + // an absent one, not collapse the tail onto dur=0. #[test] fn short_stts_drops_the_track_like_an_absent_one() { use std::io::Cursor; @@ -2852,9 +2830,8 @@ mod tests { assert_eq!(mdhd_language(&v1).as_deref(), Some("eng")); } - // The length guard is `b.len() < off + 2` — reject too SHORT, not "not - // exactly `off + 2`"; pins both edges of that boundary. - // See docs/mp4-read.md — mdhd_language_boundary_rejects_short. + // The length guard is `b.len() < off + 2` — reject too SHORT, not "not exactly `off + 2`"; + // pins both edges of that boundary. #[test] fn mdhd_language_boundary_rejects_short_not_merely_non_exact() { let short = vec![0u8; 20]; // == off, zero room for the language field @@ -2906,9 +2883,8 @@ mod tests { b } - // `height` is the SECOND 16-bit dimension in a VisualSampleEntry, at byte - // 26 (width at 24); reading the wrong one is silent, so width != height here. - // See docs/mp4-read.md — parse_stsd_takes_height. + // `height` is the SECOND 16-bit dimension in a VisualSampleEntry, at byte 26 (width at 24); + // reading the wrong one is silent, so width != height here. #[test] fn parse_stsd_takes_height_from_its_own_field_not_the_width_beside_it() { let stsd = stsd_with(b"avc1", &visual_entry(1920, 1080, &[])); @@ -2926,9 +2902,8 @@ mod tests { ); } - // `channelcount` is at byte 16 of an AudioSampleEntry; an offset slip - // reads a conventionally-zero reserved field instead. Also pins the - // short-entry fallback of 2. See docs/mp4-read.md — parse_stsd_reads_channelcount. + // `channelcount` is at byte 16 of an AudioSampleEntry; an offset slip reads a + // conventionally-zero reserved field instead. Also pins the short-entry fallback of 2. #[test] fn parse_stsd_reads_channelcount_from_its_own_field_and_defaults_a_short_entry() { let stsd = stsd_with(b"ac-3", &audio_entry(6, 0xBEEF, &[])); @@ -2949,9 +2924,8 @@ mod tests { ); } - // `mp4a` codec_private is only read when the entry is `Codec::Aac` AND - // its body is >= the 28-byte fixed part, else `find_box(&body[28..], ...)` - // would slice past the end. See docs/mp4-read.md — parse_stsd_extracts_aac_codec_private. + // `mp4a` codec_private is only read when the entry is `Codec::Aac` AND its body is >= the + // 28-byte fixed part, else `find_box(&body[28..],...)` would slice past the end. #[test] fn parse_stsd_extracts_aac_codec_private_only_when_the_body_is_long_enough() { let asc = vec![0x12u8, 0x10]; // AAC-LC 44.1 kHz stereo @@ -2987,9 +2961,8 @@ mod tests { ); } - // Every recognised audio fourcc must map to its own `Codec`; a deleted - // match arm falls through to `_ => return None`, dropping the track. - // See docs/mp4-read.md — parse_stsd_recognises_every_audio_fourcc. + // Every recognised audio fourcc must map to its own `Codec`; a deleted match arm falls + // through to `_ => return None`, dropping the track. #[test] fn parse_stsd_recognises_every_audio_fourcc() { let cases: &[(&[u8; 4], Codec)] = &[ @@ -3020,7 +2993,7 @@ mod tests { // ── MPEG-4 expandable descriptors (ISO/IEC 14496-1 §8.3.3) ──────────────── - // Base-128 varint, max FOUR bytes. See docs/mp4-read.md — read_descriptor_len. + // Base-128 varint, max FOUR bytes. #[test] fn read_descriptor_len_is_a_four_byte_base_128_varint() { let read = |b: &[u8]| { @@ -3052,9 +3025,8 @@ mod tests { assert_eq!(read(&[0x81]), (1, 1), "a dangling continuation just ends"); } - // The optional `ES_Descriptor` fields (§7.2.6.5) are selected by three - // flag bits; skipping them wrongly loses the AAC track's CodecPrivate. - // See docs/mp4-read.md — parse_esds_asc_steps_over_every_optional_field. + // The optional `ES_Descriptor` fields (§7.2.6.5) are selected by three flag bits; skipping + // them wrongly loses the AAC track's CodecPrivate. #[test] fn parse_esds_asc_steps_over_every_optional_es_descriptor_field() { let asc = vec![0x12u8, 0x10]; // AAC-LC 44.1 kHz stereo @@ -3103,9 +3075,8 @@ mod tests { ); } - // A box header is 8 bytes, so a declared `size` below 8 cannot describe - // a box; taking it at face value would slice past the start and panic. - // See docs/mp4-read.md — find_boxes_capped_refuses_a_box_smaller_than_header. + // A box header is 8 bytes, so a declared `size` below 8 cannot describe a box; taking it at + // face value would slice past the start and panic. #[test] fn find_boxes_capped_refuses_a_box_smaller_than_its_own_header() { // size = 4, type = 'avcC': shorter than the header that declares it. @@ -3140,9 +3111,8 @@ mod tests { ); } - // The declared box size decodes from four specific bytes; a size with - // distinct bytes catches an index slip a small, mostly-zero size would hide. - // See docs/mp4-read.md — find_boxes_capped_decodes_the_size_field. + // The declared box size decodes from four specific bytes; a size with distinct bytes + // catches an index slip a small, mostly-zero size would hide. #[test] fn find_boxes_capped_decodes_the_size_field_from_its_own_bytes() { let want_size: u32 = 0x0001_0010; // byte0=0x00 byte1=0x01 byte2=0x00 byte3=0x10 @@ -3159,10 +3129,9 @@ mod tests { ); } - /// A crafted 64-bit `largesize` near u64::MAX at a nonzero `pos` must not - /// wrap `pos + box_size` past the length guard: the scan must reject it and - /// return without a release panic (slice) or an infinite loop (advance). - /// See docs/mp4-read.md — find_boxes_capped_rejects_a_hostile_largesize. + /// A crafted 64-bit `largesize` near u64::MAX at a nonzero `pos` must not wrap `pos + + /// box_size` past the length guard: the scan must reject it and return without a release + /// panic (slice) or an infinite loop (advance). #[test] fn find_boxes_capped_rejects_a_hostile_largesize_near_u64_max() { let mut payload = Vec::new(); @@ -3183,9 +3152,8 @@ mod tests { assert!(find_box(&payload, b"test").is_none()); } - // An `stsc` entry naming a `first_chunk` beyond what `stco` declares must - // be clamped, not index `spc` past its length and panic. - // See docs/mp4-read.md — sample_offsets_clamps_an_stsc_run. + // An `stsc` entry naming a `first_chunk` beyond what `stco` declares must be clamped, not + // index `spc` past its length and panic. #[test] fn sample_offsets_clamps_an_stsc_run_that_outruns_the_chunk_table() { let sizes = [10u32, 20]; @@ -3200,9 +3168,8 @@ mod tests { ); } - // §8.6.6: an edit list may hold several media edits; this frame model - // can only express a constant shift, so it honours the LEADING one, not the last. - // See docs/mp4-read.md — elst_offset_ticks_honours_the_first_media_edit. + // §8.6.6: an edit list may hold several media edits; this frame model can only express a + // constant shift, so it honours the LEADING one, not the last. #[test] fn elst_offset_ticks_honours_the_first_media_edit_not_the_last() { // Two non-empty edits with different media_time; no empty edit. @@ -3227,9 +3194,9 @@ mod tests { ); } - // A version-1 `mvhd` carries 64-bit creation/modification times, so its - // `timescale` sits at byte 20 rather than 12 (§8.2.2); writers emit - // version 1 whenever movie duration doesn't fit 32 bits. See docs/mp4-read.md — mvhd_timescale_version_1. + // A version-1 `mvhd` carries 64-bit creation/modification times, so its `timescale` sits at + // byte 20 rather than 12 (§8.2.2); writers emit version 1 whenever movie duration doesn't + // fit 32 bits. #[test] fn mvhd_timescale_version_1_reads_past_the_64_bit_times() { let mut v1 = vec![0u8; 24]; @@ -3250,9 +3217,8 @@ mod tests { assert_eq!(mvhd_timescale(&v0), Some(600), "v0 timescale is at 12"); } - // Only the leading empty edits and the FIRST media edit shape the offset, - // so `MAX_ELST_ENTRIES` bounds what a crafted `elst` can allocate. - // See docs/mp4-read.md — parse_elst_entry_count_is_capped. + // Only the leading empty edits and the FIRST media edit shape the offset, so + // `MAX_ELST_ENTRIES` bounds what a crafted `elst` can allocate. #[test] fn parse_elst_entry_count_is_capped() { let n = MAX_ELST_ENTRIES + 500; @@ -3345,9 +3311,8 @@ mod tests { mp4_box(b"trak", &mp4_box(b"mdia", &mdia)) } - // A `hdlr` of anything other than `vide`/`soun` (e.g. hint/subtitle) - // must be dropped, not folded into the audio branch. - // See docs/mp4-read.md — non_av_handler_track_is_dropped. + // A `hdlr` of anything other than `vide`/`soun` (e.g. hint/subtitle) must be dropped, not + // folded into the audio branch. #[test] fn non_av_handler_track_is_dropped_not_folded_into_audio() { use std::io::Cursor; @@ -3366,9 +3331,8 @@ mod tests { ); } - // Per-track PID is `0x1011 + track_idx` (video) / `0x1100 + track_idx` - // (audio); at track_idx==0 a `+`↔`-`/`*` mutant is invisible, so this - // pushes track_idx past 0. See docs/mp4-read.md — per_track_pid_arithmetic. + // Per-track PID is `0x1011 + track_idx` (video) / `0x1100 + track_idx` (audio); at + // track_idx==0 a `+`↔`-`/`*` mutant is invisible, so this pushes track_idx past 0. #[test] fn per_track_pid_arithmetic_is_exact_past_the_first_track() { use crate::disc::Stream as DiscStreamE; @@ -3409,9 +3373,8 @@ mod tests { ); } - // A track whose `stsz` is absent still occupies a `track_idx` slot and - // must advance the counter, else two tracks collide on the same PID. - // See docs/mp4-read.md — track_idx_advances_past_a_sample_less_track. + // A track whose `stsz` is absent still occupies a `track_idx` slot and must advance the + // counter, else two tracks collide on the same PID. #[test] fn track_idx_advances_past_a_sample_less_track() { use crate::disc::Stream as DiscStreamE; @@ -3437,9 +3400,8 @@ mod tests { } } - // The global `sample_budget` is DECREMENTED by each track's real sample - // count and SHARED across tracks; a late track gets only what's left. - // See docs/mp4-read.md — sample_budget_is_shared_and_exhausted. + // The global `sample_budget` is DECREMENTED by each track's real sample count and SHARED + // across tracks; a late track gets only what's left. #[test] fn sample_budget_is_shared_and_exhausted_across_tracks() { use std::io::Cursor; diff --git a/src/mux/network.rs b/src/mux/network.rs index ba735524..2a40feee 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -14,9 +14,8 @@ use std::net::{IpAddr, TcpListener, TcpStream, ToSocketAddrs}; /// I/O buffer size for network reads/writes. const NET_BUF_SIZE: usize = 256 * 1024; -// True if `ip` must never be a `network://` connect target (loopback, -// private, link-local, unspecified, multicast). Re-checked here at -// connect time to close a DNS-rebinding TOCTOU. See docs/network-mod.md. +// True if `ip` must never be a `network://` connect target (loopback, private, link-local, +// unspecified, multicast). Re-checked here at connect time to close a DNS-rebinding TOCTOU. pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => { @@ -706,9 +705,8 @@ mod tests { ); } - // accept_from() must reject a connection whose first bytes aren't the - // FMKV magic, surfacing NoMetadata rather than an empty/garbage title. - // See docs/network-mod.md for why this test half-closes, not full-closes. + // accept_from() must reject a connection whose first bytes aren't the FMKV magic, surfacing + // NoMetadata rather than an empty/garbage title. #[test] fn accept_from_rejects_stream_without_fmkv_header() { use std::io::Read as _; diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 6503a724..7c9e6075 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -9,9 +9,6 @@ //! Thread B: M2TS demux (DemuxThread) //! Thread C: codec parse (this struct, on the caller's thread) //! ``` -//! -//! See docs/pipelined-stream.md for the full threading/channel model and -//! how the ISO vs M2TS input paths construct this type. use super::codec::CodecParser; use super::demux_thread::{DemuxBatch, DemuxThread}; @@ -1076,9 +1073,8 @@ mod tests { au } - // DVD seek-index regression at the HIGHWAY level (PsDemuxer → - // PipelinedPesStream → frame out). Keyframe flag/duration must survive so - // the muxer's cluster/cue logic fires. See docs/pipelined-stream.md. + // DVD seek-index regression at the HIGHWAY level (PsDemuxer → PipelinedPesStream → frame + // out). Keyframe flag/duration must survive so the muxer's cluster/cue logic fires. #[test] fn dvd_highway_preserves_video_keyframe_and_duration() { use crate::mux::codec::mpeg2::Mpeg2Parser; diff --git a/src/mux/ps.rs b/src/mux/ps.rs index 73fac2cd..d09ec4dc 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -24,14 +24,12 @@ const PRIVATE_STREAM_1: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_1; /// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable /// elementary stream; expected to be dropped on every disc. const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2; -// Extended stream id (0xFD) — H.222.0 escape: real id is the -// `stream_id_extension` in the PES extension. HD-DVD `.evo` VC-1/HD audio. -// See docs/ps-mod.md — Stream-ID constant rationale. +// Extended stream id (0xFD) — H.222.0 escape: real id is the `stream_id_extension` in the PES +// extension. HD-DVD `.evo` VC-1/HD audio. const EXTENDED_STREAM_ID: u8 = 0xFD; -// Hard cap on the demuxer's reassembly buffer, so a corrupt unbounded PES -// can't drive unbounded allocation. -// See docs/ps-mod.md — Stream-ID constant rationale. +// Hard cap on the demuxer's reassembly buffer, so a corrupt unbounded PES can't drive unbounded +// allocation. const MAX_PS_BUFFER: usize = 4 * 1024 * 1024; /// A demuxed PES packet from the Program Stream. @@ -62,10 +60,9 @@ pub const DVD_VIDEO_PID: u16 = 0xE0; /// on-wire sub-stream id. Returns `None` for sub-ids outside the AC-3 / /// DTS / LPCM / HD-DVD E-AC-3 ranges. /// -/// The PID is `0xBD00 | sub_stream_id`, unique per sub-stream id (AC-3 / -/// DTS `0x80..=0x8F`, LPCM `0xA0..=0xA7`, HD-DVD E-AC-3 `0xC0..=0xC7`) — the -/// single source of truth shared with `Disc::scan_dvd_titles` -/// (`src/disc/dvd.rs`). See docs/ps-mod.md — `dvd_audio_pid` rationale. +/// The PID is `0xBD00 | sub_stream_id`, unique per sub-stream id (AC-3 / DTS `0x80..=0x8F`, +/// LPCM `0xA0..=0xA7`, HD-DVD E-AC-3 `0xC0..=0xC7`) — the single source of truth shared with +/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`). pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> { match sub_stream_id { 0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xC7 => Some(0xBD00 | sub_stream_id as u16), @@ -103,8 +100,8 @@ impl PsPacket { /// shared [`dvd_audio_pid`] / [`dvd_subtitle_pid`] tables the scanner /// also uses. /// - /// Returns `None` for combinations the DVD title scanner does not - /// assign a PID to; the caller should WARN-and-drop. See docs/ps-mod.md. + /// Returns `None` for combinations the DVD title scanner does not assign a PID to; the + /// caller should WARN-and-drop. pub fn dvd_pid(&self) -> Option<u16> { match self.stream_id { crate::consts::pes_stream_id::VIDEO..=0xEF => Some(DVD_VIDEO_PID), @@ -149,8 +146,8 @@ pub struct PsDemuxer { /// is no boundary)`. Buffer-relative; rebased when the buffer drains, /// cleared when the PES is emitted. /// - /// Without it, every `feed` would re-search the whole accumulated - /// payload from the PES header. See docs/ps-mod.md — `PsDemuxer::pending_scan`. + /// Without it, every `feed` would re-search the whole accumulated payload from the PES + /// header. pending_scan: Option<(usize, usize)>, /// Test-only: total bytes examined by `find_ps_boundary`. Pins the cursor /// above — the property it exists for is a WORK bound, which no @@ -371,9 +368,8 @@ impl PsDemuxer { /// straddle a feed boundary by at most its first two bytes. const START_CODE_PREFIX_KEEP: usize = 2; -// Next PS-layer unit boundary at/after `from` — NOT the next raw `00 00 01`, -// which the video ES payload is full of. Returns `(boundary, searched_to)`. -// See docs/ps-mod.md — `find_ps_boundary`. +// Next PS-layer unit boundary at/after `from` — NOT the next raw `00 00 01`, which the video ES +// payload is full of. Returns `(boundary, searched_to)`. fn find_ps_boundary(data: &[u8], from: usize) -> (Option<usize>, usize) { let mut pos = from; while let Some(sc) = find_start_code(data, pos) { @@ -564,8 +560,8 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> { }) } -// Parse a 5-byte PTS/DTS timestamp field (33 bits at 90kHz), layout per -// ISO/IEC 13818-1 Table 2-17. See docs/ps-mod.md — `parse_pts` byte layout. +// Parse a 5-byte PTS/DTS timestamp field (33 bits at 90kHz), layout per ISO/IEC 13818-1 Table +// 2-17. fn parse_pts(buf: &[u8]) -> Option<u64> { debug_assert!(buf.len() >= 5); // Validate the three marker bits (bit 0 of bytes 0, 2, 4) per MPEG-2 @@ -1324,9 +1320,8 @@ mod tests { assert_eq!(p[0].data, vec![0xDE, 0xAD, 0xBE, 0xEF]); } - // `is_nav()` separates the one expected-unmappable DVD stream - // (private_stream_2 nav packs) from any other `dvd_pid() == None`, which - // is an unexpected lost stream. See docs/ps-mod.md — this test. + // `is_nav()` separates the one expected-unmappable DVD stream (private_stream_2 nav packs) + // from any other `dvd_pid() == None`, which is an unexpected lost stream. #[test] fn only_private_stream_2_is_navigation_and_never_a_routable_stream() { // Demux a program stream carrying, in order: a navigation pack, MPEG-2 @@ -1587,17 +1582,15 @@ mod tests { // Mutation-gap hardening (mux-ts pass) // ════════════════════════════════════════════════════════════════════ - /// Pins `MAX_PS_BUFFER` against a literal computed independently, so a - /// mutated expression in its definition can't hide behind - /// self-referential assertions. See docs/ps-mod.md. + /// Pins `MAX_PS_BUFFER` against a literal computed independently, so a mutated expression + /// in its definition can't hide behind self-referential assertions. #[test] fn max_ps_buffer_has_the_documented_value() { assert_eq!(MAX_PS_BUFFER, 4 * 1024 * 1024); } - // Pack-header framing must accept an EXACT fit, not wait for data that - // will never come; preceded by an unrelated start code so `sc != 0`. - // See docs/ps-mod.md — this test. + // Pack-header framing must accept an EXACT fit, not wait for data that will never come; + // preceded by an unrelated start code so `sc != 0`. #[test] fn pack_header_exact_fit_is_consumed_not_awaited() { // Case 1: mandatory 14 bytes, no stuffing, nothing else buffered. @@ -1631,9 +1624,8 @@ mod tests { ); } - // System-header framing needs exactly `6 + header_length` bytes; at - // `header_length == 0` both boundary checks coincide at `len == 6`. - // See docs/ps-mod.md — this test. + // System-header framing needs exactly `6 + header_length` bytes; at `header_length == 0` + // both boundary checks coincide at `len == 6`. #[test] fn system_header_zero_length_exact_fit_is_consumed_not_awaited() { let mut demuxer = PsDemuxer::new(); @@ -1645,9 +1637,9 @@ mod tests { ); } - // `header_len` is 16-bit big-endian; a `<<`->`>>` mutation would misread - // 300 (0x012C) as just 44. A decoy start code at the offset that misread - // length would resume at must stay buried. See docs/ps-mod.md. + // `header_len` is 16-bit big-endian; a `<<`->`>>` mutation would misread 300 (0x012C) as + // just 44. A decoy start code at the offset that misread length would resume at must stay + // buried. #[test] fn system_header_length_high_byte_is_not_dropped() { let mut demuxer = PsDemuxer::new(); @@ -1677,8 +1669,8 @@ mod tests { assert_eq!(packets[0].data, vec![0x77, 0x88]); } - // `sc + 3 >= data.len()` must stay an ADDITION: `+`->`-` at `sc == 0` - // underflows and panics on a bare trailing start code. See docs/ps-mod.md. + // `sc + 3 >= data.len()` must stay an ADDITION: `+`->`-` at `sc == 0` underflows and panics + // on a bare trailing start code. #[test] fn find_ps_boundary_handles_a_bare_start_code_at_the_buffer_head() { assert_eq!( @@ -1688,9 +1680,8 @@ mod tests { ); } - // An unbounded PES's boundary search must not restart at the PES header - // on every feed (quadratic work). `boundary_bytes_scanned` measures the - // work bound directly. See docs/ps-mod.md — this test. + // An unbounded PES's boundary search must not restart at the PES header on every feed + // (quadratic work). `boundary_bytes_scanned` measures the work bound directly. #[test] fn an_unterminated_pes_is_not_rescanned_from_its_header_every_feed() { const CHUNKS: usize = 256; @@ -1742,9 +1733,9 @@ mod tests { ); } - // The resume cursor is a buffer offset and must be rebased on drain — - // the only test where a drain happens while the cursor is live, so both - // halves of the rebase arithmetic get exercised. See docs/ps-mod.md. + // The resume cursor is a buffer offset and must be rebased on drain — the only test where a + // drain happens while the cursor is live, so both halves of the rebase arithmetic get + // exercised. #[test] fn a_resume_cursor_survives_the_drain_of_units_ahead_of_the_unbounded_pes() { // Payload fed in the SAME chunk that opens the PES. Large enough that @@ -1838,9 +1829,9 @@ mod tests { ); } - // Arms EVERY optional PES-header/PES_extension field at once with a known - // byte count, so a single mutated `pos +=` skip anywhere in the walk to - // `stream_id_extension` lands on the wrong byte. See docs/ps-mod.md. + // Arms EVERY optional PES-header/PES_extension field at once with a known byte count, so a + // single mutated `pos +=` skip anywhere in the walk to `stream_id_extension` lands on the + // wrong byte. #[test] fn parse_stream_id_extension_walks_every_optional_field_to_the_right_offset() { // flags2: PTS/DTS absent (00), ESCR/ES_rate/DSM_trick_mode/ @@ -1880,9 +1871,9 @@ mod tests { assert_eq!(parsed.data, es); } - // A length-bounded PES must be emitted the moment its declared length is - // EXACTLY satisfied, not held back — a `>=` vs `>` boundary-check bug - // would misclassify an exact fit as "not enough data". See docs/ps-mod.md. + // A length-bounded PES must be emitted the moment its declared length is EXACTLY satisfied, + // not held back — a `>=` vs `>` boundary-check bug would misclassify an exact fit as "not + // enough data". #[test] fn length_bounded_pes_exact_fit_is_emitted_not_awaited() { let mut demuxer = PsDemuxer::new(); diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index d8f599e4..deeb072d 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -1,7 +1,6 @@ //! Stream URL resolver — parses URL strings into PES stream instances. //! -//! Format: `scheme://path`. See docs/resolve.md for the full scheme table. -//! Bare paths without a scheme are rejected. +//! Format: `scheme://path`. //! //! For disc→ISO (raw sector copy), use `freemkv_engine::recovery::copy` instead. //! @@ -71,9 +70,8 @@ pub enum StreamUrl { /// restricted to subtitle tracks (PGS `.sup`, VobSub `.idx`+`.sub`, text /// `.srt`). One file per subtitle track. Sub { dir: PathBuf }, - /// freemkv native per-picture video index (`fvi://`). A write-only PES sink - /// that emits one JSON-Lines record per coded picture of the title's primary - /// video track to a `.fvi` file (normative spec `docs/FVI_FORMAT.md`). + /// freemkv native per-picture video index (`fvi://`). A write-only PES sink that emits one + /// JSON-Lines record per coded picture of the title's primary video track to a `.fvi` file. Fvi { path: PathBuf }, /// Chapter-marker export (`chapters://`). A write-only sink that ignores the /// PES stream and writes the title's chapter points to a single file, format @@ -440,7 +438,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S } // Shared body of every IMAGE-level PES source (`iso://`/`dir://`). -// See docs/resolve.md#image_input-shared-image-level-source-body. fn image_input<S, F>( mut reader: S, opts: &InputOptions, @@ -602,13 +599,11 @@ where /// Open a PES output stream (consumes PES frames). /// -/// `source` is the provenance of the material being written — the INPUT the -/// caller is muxing from, not `url`. Only the `fvi://` sink consumes it (it -/// records the input in the index header, `docs/FVI_FORMAT.md` §6.2); every -/// other sink ignores it. `None` means "no provenance to declare": the header's -/// `source` members then carry their neutral defaults and the optional ones are -/// omitted, rather than being back-filled with the destination path — which is -/// exactly the bug this parameter exists to prevent. +/// `source` is the provenance of the material being written — the INPUT the caller is muxing +/// from, not `url`. Only the `fvi://` sink consumes it; every other sink ignores it. `None` +/// means "no provenance to declare": the header's `source` members then carry their neutral +/// defaults and the optional ones are omitted, rather than being back-filled with the +/// destination path — which is exactly the bug this parameter exists to prevent. pub fn output( url: &str, title: &crate::disc::DiscTitle, @@ -702,9 +697,9 @@ pub fn output( dir, title, &opts, )?)) } - // `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`). - // The header's `source` describes the INPUT, from caller-supplied - // `source` — never `path`, which previously broke reproducibility. + // `fvi://` writes the per-picture video index. The header's `source` describes the + // INPUT, from caller-supplied `source` — never `path`, which previously broke + // reproducibility. StreamUrl::Fvi { ref path } => { validate_file_path(path, "fvi")?; Ok(Box::new(super::fvi_sink::FviSink::create( @@ -779,13 +774,12 @@ pub(crate) fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> Dem (parsers, pid_to_track, ts, ps) } -// Tag for an FMTS forensic index key banked into the key pool (base + slot); -// separates disc BASE CPS unit keys (< this) from forensic ones (>= this). -// See docs/resolve.md#fmts_pool_tag_base. +// Tag for an FMTS forensic index key banked into the key pool (base + slot); separates disc +// BASE CPS unit keys (< this) from forensic ones (>= this). const FMTS_POOL_TAG_BASE: u32 = 1 << 24; -// Pool slot of the disc's ONE base CPS Unit Key, or None for a genuine -// multi-CPS disc; excludes forensic index keys. See docs/resolve.md#single_base_key_slot. +// Pool slot of the disc's ONE base CPS Unit Key, or None for a genuine multi-CPS disc; excludes +// forensic index keys. fn single_base_key_slot(unit_keys: &[(u32, [u8; 16])]) -> Option<usize> { match base_key_slots(unit_keys)[..] { [only] => Some(only), @@ -861,9 +855,8 @@ fn pick_pool_slot( }) } -// Which CPS unit's BASE Unit Key opens `ext`, as a pool slot; memoised per-disc. -// Only base keys are considered (forensic index keys are mapped by tag earlier). -// See docs/resolve.md#base_slot_for_extent. +// Which CPS unit's BASE Unit Key opens `ext`, as a pool slot; memoised per-disc. Only base keys +// are considered (forensic index keys are mapped by tag earlier). fn base_slot_for_extent( reader: &mut dyn SectorSource, ext: &crate::disc::Extent, @@ -911,9 +904,8 @@ fn base_slot_for_extent( } } -// FMTS (AACS 2.1) branch of resolve_mux_key_map: resolves per-segment index keys -// (from the configured source) plus base-key gap fill, memoised per disc. -// See docs/resolve.md#resolve_fmts_key_map. +// FMTS (AACS 2.1) branch of resolve_mux_key_map: resolves per-segment index keys (from the +// configured source) plus base-key gap fill, memoised per disc. #[allow(clippy::too_many_arguments)] fn resolve_fmts_key_map( reader: &mut dyn SectorSource, @@ -1142,9 +1134,8 @@ struct FmtsIndexKeys { all_phases_definite: bool, } -// Drive/key-service-hitting half of resolve_fmts_key_map: anchor the disc's -// forensic index-key set from one round trip, then probe each index's phase. -// See docs/resolve.md#probe_fmts_index_keys. +// Drive/key-service-hitting half of resolve_fmts_key_map: anchor the disc's forensic index-key +// set from one round trip, then probe each index's phase. fn probe_fmts_index_keys( reader: &mut dyn SectorSource, clip_extents: &[crate::disc::Extent], @@ -1270,9 +1261,8 @@ fn probe_fmts_index_keys( }) } -// FMTS forensic feature clip's own extents — the byte space every -// `IndividualSegment.tbl` SPN is relative to — or None if not exactly one such clip. -// See docs/resolve.md#forensic_clip_extents. +// FMTS forensic feature clip's own extents — the byte space every `IndividualSegment.tbl` SPN +// is relative to — or None if not exactly one such clip. fn forensic_clip_extents( udf: &crate::udf::UdfFs, reader: &mut dyn SectorSource, @@ -1321,9 +1311,9 @@ fn extents_overlap(a: &[crate::disc::Extent], b: &[crate::disc::Extent]) -> bool }) } -// Keep only forensic segments addressable within the FORENSIC CLIP's extents; -// stale/foreign records past the clip's end are dropped. Extracted from -// resolve_fmts_key_map for direct testing; see docs/resolve.md#filter_addressable_segments. +// Keep only forensic segments addressable within the FORENSIC CLIP's extents; stale/foreign +// records past the clip's end are dropped. Extracted from resolve_fmts_key_map for direct +// testing. fn filter_addressable_segments( segments: Vec<crate::aacs::segment::Segment>, extents: &[crate::disc::Extent], @@ -1336,9 +1326,8 @@ fn filter_addressable_segments( .collect() } -// Decide a forensic index's decrypt phase from clean-sample counts of its EVEN -// vs ODD aligned units under that index's key. Extracted for unit-testing. -// See docs/resolve.md#resolve_tie_phase. +// Decide a forensic index's decrypt phase from clean-sample counts of its EVEN vs ODD aligned +// units under that index's key. Extracted for unit-testing. fn resolve_tie_phase(even_clean: usize, odd_clean: usize) -> io::Result<crate::decrypt::Phase> { match even_clean.cmp(&odd_clean) { std::cmp::Ordering::Greater => Ok(crate::decrypt::Phase::Even), @@ -1350,9 +1339,8 @@ fn resolve_tie_phase(even_clean: usize, odd_clean: usize) -> io::Result<crate::d } } -// Outcome of probing ONE forensic index's decrypt phase; the load-bearing split -// is WrongKey vs ReadFault — only the former is a real FmtsKeyMissing. -// See docs/resolve.md#indexprobe. +// Outcome of probing ONE forensic index's decrypt phase; the load-bearing split is WrongKey vs +// ReadFault — only the former is a real FmtsKeyMissing. #[derive(Debug, PartialEq, Eq)] enum IndexProbe { /// A parity decrypted clean under this index's key (or a padding tie) → its phase. @@ -1366,9 +1354,8 @@ enum IndexProbe { ReadFault, } -// Probe one forensic index's decrypt phase (EVEN vs ODD aligned units) under -// `key`, tolerating read faults without masking a genuine wrong key. -// See docs/resolve.md#probe_index_phase. +// Probe one forensic index's decrypt phase (EVEN vs ODD aligned units) under `key`, tolerating +// read faults without masking a genuine wrong key. fn probe_index_phase( segments: &[crate::aacs::segment::Segment], tag: u16, @@ -1418,9 +1405,8 @@ fn probe_index_phase( } } -// Back-fill LBA gaps NOT covered by forensic segment ranges with the base Unit -// Key, so the map is a COMPLETE positive list over the title's content extents. -// See docs/resolve.md#fill_base_key_gaps. +// Back-fill LBA gaps NOT covered by forensic segment ranges with the base Unit Key, so the map +// is a COMPLETE positive list over the title's content extents. fn fill_base_key_gaps( extents: &[crate::disc::Extent], forensic_ranges: &[(u32, u32, usize, crate::decrypt::Phase)], @@ -1467,10 +1453,9 @@ fn content_map(title: &DiscTitle, idx: usize) -> crate::decrypt::AacsKeyMap { /// LBA ranges and secures any key the pool is missing through the app's /// configured source (`fetch`) up front, never reactively per unit at mux time. /// -/// Single-CPS (the overwhelming majority) keys every extent with one index; -/// multi-CPS keys each with the key opening a real sample; FMTS layers -/// per-segment keys on top. LBAs outside the title's content pass through. -/// See docs/resolve.md#resolve_mux_key_map for why this ends the key-server storm. +/// Single-CPS (the overwhelming majority) keys every extent with one index; multi-CPS keys each +/// with the key opening a real sample; FMTS layers per-segment keys on top. LBAs outside the +/// title's content pass through. pub fn resolve_mux_key_map( reader: &mut dyn SectorSource, title: &DiscTitle, @@ -1492,19 +1477,17 @@ pub fn resolve_mux_key_map( ) } -// Memoises the multi-CPS "which held unit key opens this extent" decision across -// the titles of ONE disc, for resolve_mux_key_map_cached. Keyed by content format -// plus the extent's exact (start_lba, sector_count). See docs/resolve.md#cpsunitcache. +// Memoises the multi-CPS "which held unit key opens this extent" decision across the titles of +// ONE disc, for resolve_mux_key_map_cached. Keyed by content format plus the extent's exact +// (start_lba, sector_count). pub(crate) type CpsUnitCache = std::collections::HashMap<(ContentFormat, u32, u32), usize>; -// The disc's forensic segment table (/AACS/IndividualSegment.tbl), resolved at -// most ONCE per disc: None = not looked for yet; Some(None) = not FMTS; -// Some(Some(v)) = parsed segments. See docs/resolve.md#fmtstablecache. +// The disc's forensic segment table (/AACS/IndividualSegment.tbl), resolved at most ONCE per +// disc: None = not looked for yet; Some(None) = not FMTS; Some(Some(v)) = parsed segments. pub(crate) type FmtsTableCache = Option<Option<Vec<crate::aacs::segment::Segment>>>; -// Memoises the FMTS forensic index-key set + per-index phase (the expensive half -// of resolve_fmts_key_map) across the titles of ONE disc; key material, never -// logged. See docs/resolve.md#fmtskeycache. +// Memoises the FMTS forensic index-key set + per-index phase (the expensive half of +// resolve_fmts_key_map) across the titles of ONE disc; key material, never logged. pub(crate) type FmtsKeyCache = std::collections::HashMap< (ContentFormat, Vec<(u32, u32)>), ( @@ -1513,9 +1496,8 @@ pub(crate) type FmtsKeyCache = std::collections::HashMap< ), >; -// The disc's forensic feature clip extents, resolved in the SAME UDF walk as -// FmtsTableCache, at most once per disc (None/Some(None)/Some(Some(v)) as there). -// See docs/resolve.md#fmtsclipcache. +// The disc's forensic feature clip extents, resolved in the SAME UDF walk as FmtsTableCache, at +// most once per disc (None/Some(None)/Some(Some(v)) as there). pub(crate) type FmtsClipCache = Option<Option<Vec<crate::disc::Extent>>>; /// The three FMTS memos, bundled so [`resolve_fmts_key_map`] takes one argument for @@ -1559,9 +1541,8 @@ fn extent_key(format: ContentFormat, title: &DiscTitle) -> (ContentFormat, Vec<( ) } -// resolve_mux_key_map with a caller-owned per-disc cache (DiscKeyCache) shared -// across the titles of one disc. Safe because every memoised value is a disc -// fact, not a title fact. See docs/resolve.md#resolve_mux_key_map_cached. +// resolve_mux_key_map with a caller-owned per-disc cache (DiscKeyCache) shared across the +// titles of one disc. Safe because every memoised value is a disc fact, not a title fact. pub(crate) fn resolve_mux_key_map_cached( reader: &mut dyn SectorSource, title: &DiscTitle, @@ -1687,11 +1668,10 @@ pub(crate) fn resolve_mux_key_map_cached( /// a `FileSectorSource`-backed reader. Returns the resulting /// `PipelinedPesStream`. /// -/// `keys` pass [`crate::decrypt::DecryptKeys::None`] for raw/unencrypted reads. -/// `raw` skips the per-title CSS crack entirely (ciphertext passthrough). -/// `halt` is a cooperative cancel token, not a timeout. `fetch` is used UP -/// FRONT by [`resolve_mux_key_map`], never per-unit at mux time. -/// See docs/resolve.md#build_iso_pipeline-parameters for the full parameter list. +/// `keys` pass [`crate::decrypt::DecryptKeys::None`] for raw/unencrypted reads. `raw` skips the +/// per-title CSS crack entirely (ciphertext passthrough). `halt` is a cooperative cancel token, +/// not a timeout. `fetch` is used UP FRONT by [`resolve_mux_key_map`], never per-unit at mux +/// time. #[allow(clippy::too_many_arguments)] pub fn build_iso_pipeline<S: SectorSource + Send + 'static>( mut reader: S, @@ -1885,9 +1865,8 @@ mod tests { use crate::sector::SectorSource; use std::path::PathBuf; - // parse_url must never panic on ANY input (the front door for caller URLs). - // Feeds adversarial strings + every byte 0x00..=0xFF; any StreamUrl is OK. - // See docs/resolve.md#parse_url_never_panics_on_adversarial_input. + // parse_url must never panic on ANY input (the front door for caller URLs). Feeds + // adversarial strings + every byte 0x00..=0xFF; any StreamUrl is OK. #[test] fn parse_url_never_panics_on_adversarial_input() { let mut cases: Vec<String> = vec![ @@ -2035,8 +2014,7 @@ mod tests { serde_json::from_str(text.lines().next().unwrap()).unwrap() } - // fvi:// must record the SOURCE, not the destination path (docs/FVI_FORMAT.md - // §6.2). See docs/resolve.md#fvi_output_records_the_source_not_the_destination. + // fvi:// must record the SOURCE, not the destination path. #[test] fn fvi_output_records_the_source_not_the_destination() { let dir = fvi_tempdir(); @@ -2062,8 +2040,7 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - // Two runs indexing the SAME source must be byte-identical regardless of - // destination. See docs/resolve.md#fvi_output_is_reproducible_across_destination_paths. + // Two runs indexing the SAME source must be byte-identical regardless of destination. #[test] fn fvi_output_is_reproducible_across_destination_paths() { let dir = fvi_tempdir(); @@ -2169,8 +2146,8 @@ mod tests { ); } - // dir://PATH/ parses to StreamUrl::Dir and IS an image-level source (1.6.1), - // unlike demux://fvi:// sinks. See docs/resolve.md#parse_dir_url_is_an_image_source_unlike_the_directory_sinks. + // dir://PATH/ parses to StreamUrl::Dir and IS an image-level source (1.6.1), unlike + // demux://fvi:// sinks. #[test] fn parse_dir_url_is_an_image_source_unlike_the_directory_sinks() { match parse_url("dir://out/movie/") { @@ -2217,8 +2194,8 @@ mod tests { ); } - // dir:// is never a PES sink (writes raw files; CLI routes it to - // extract_tree) but IS an image source. See docs/resolve.md#dir_url_is_an_input_but_never_a_pes_sink. + // dir:// is never a PES sink (writes raw files; CLI routes it to extract_tree) but IS an + // image source. #[test] fn dir_url_is_an_input_but_never_a_pes_sink() { assert_eq!( @@ -2274,7 +2251,6 @@ mod tests { } // BdTs must build a TsDemuxer with parsers/pid_to_track keyed by stream PID. - // See docs/resolve.md#build_demux_state_bdts_builds_ts_demuxer_and_pid_table. #[test] fn build_demux_state_bdts_builds_ts_demuxer_and_pid_table() { let t = aac_audio_title(0x1100); @@ -2311,8 +2287,8 @@ mod tests { // ── Fix 1: halt threading into live-drive key resolution ─────────────── - // A counting SectorSource over zeros; touched_extent flags a read landing - // in the extent region (LBA >= 1000). See docs/resolve.md#haltcountsource. + // A counting SectorSource over zeros; touched_extent flags a read landing in the extent + // region (LBA >= 1000). struct HaltCountSource { reads: u32, touched_extent: bool, @@ -2338,9 +2314,8 @@ mod tests { } } - // resolve_mux_key_map (multi-CPS live path) must honor a pre-cancelled halt - // PROMPTLY, at the first extent boundary, before sampling any ciphertext. - // See docs/resolve.md#resolve_mux_key_map_honors_pre_cancelled_halt. + // resolve_mux_key_map (multi-CPS live path) must honor a pre-cancelled halt PROMPTLY, at + // the first extent boundary, before sampling any ciphertext. #[test] fn resolve_mux_key_map_honors_pre_cancelled_halt() { use crate::halt::Halt; @@ -2445,8 +2420,8 @@ mod tests { } } - // Build a 192-byte BD-TS data packet on pid carrying payload as the TS - // payload. Mirrors ts.rs framing. See docs/resolve.md#bdts_data_packet. + // Build a 192-byte BD-TS data packet on pid carrying payload as the TS payload. Mirrors + // ts.rs framing. fn bdts_data_packet(pid: u16, pusi: bool, payload: &[u8]) -> [u8; 192] { let mut pkt = [0u8; 192]; pkt[4] = 0x47; // sync byte @@ -2475,7 +2450,6 @@ mod tests { } // Empty extents -> clean immediate EOF, no panic/hang. - // See docs/resolve.md#build_iso_pipeline_empty_extents_clean_eof. #[test] fn build_iso_pipeline_empty_extents_clean_eof() { let title = aac_audio_title(0x1100); // extents empty by default @@ -2500,8 +2474,8 @@ mod tests { assert!(stream.read().unwrap().is_none()); } - // End-to-end: one BD-TS packet flows read -> decrypt -> demux -> parse -> - // one PesFrame, then clean EOF. See docs/resolve.md#build_iso_pipeline_delivers_one_frame_then_eof. + // End-to-end: one BD-TS packet flows read -> decrypt -> demux -> parse -> one PesFrame, + // then clean EOF. #[test] fn build_iso_pipeline_delivers_one_frame_then_eof() { let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22]; @@ -2564,8 +2538,8 @@ mod tests { ); } - // A title with TWO audio PIDs, pruned to one before build_iso_pipeline, must - // never surface a frame from the excluded PID. See docs/resolve.md#build_iso_pipeline_pruned_title_drops_unselected_pid_frames. + // A title with TWO audio PIDs, pruned to one before build_iso_pipeline, must never surface + // a frame from the excluded PID. #[test] fn build_iso_pipeline_pruned_title_drops_unselected_pid_frames() { use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate, Stream}; @@ -2663,9 +2637,8 @@ mod tests { assert!(res.is_err(), "zero batch_sectors must be rejected"); } - // REGRESSION: build_iso_pipeline for a DVD with None keys must resolve the CSS - // key itself; scrambled-uncrackable must HARD-FAIL, never mux garbage. - // See docs/resolve.md#build_iso_pipeline_dvd_none_keys_scrambled_hard_fails. + // REGRESSION: build_iso_pipeline for a DVD with None keys must resolve the CSS key itself; + // scrambled-uncrackable must HARD-FAIL, never mux garbage. #[test] fn build_iso_pipeline_dvd_none_keys_scrambled_hard_fails() { // One CSS-scrambled, crib-less (uncrackable) MPEG-PS sector. @@ -2706,8 +2679,7 @@ mod tests { // ── content_map: single-CPS positive range building ──────────────────── - // content_map(title, idx): each extent -> [start, start+count) at idx, phase - // All. See docs/resolve.md#content_map_builds_exact_ranges_from_extents. + // content_map(title, idx): each extent -> [start, start+count) at idx, phase All. #[test] fn content_map_builds_exact_ranges_from_extents() { use crate::decrypt::Phase; @@ -2762,8 +2734,8 @@ mod tests { // ── resolve_fmts_key_map decision helpers (behaviors flagged by audit) ── - // BEHAVIOR 1 — segment filter: a segment mapping inside the title's extents is - // kept, past-clip dropped, all-outside -> empty. See docs/resolve.md#filter_addressable_segments_keeps_only_in_title_segments. + // BEHAVIOR 1 — segment filter: a segment mapping inside the title's extents is kept, + // past-clip dropped, all-outside -> empty. #[test] fn filter_addressable_segments_keeps_only_in_title_segments() { use crate::aacs::segment::Segment; @@ -2810,8 +2782,7 @@ mod tests { ); } - // BEHAVIOR 2 — phase-tie default: all four arms of the even/odd clean-count - // decision. See docs/resolve.md#resolve_tie_phase_covers_all_arms. + // BEHAVIOR 2 — phase-tie default: all four arms of the even/odd clean-count decision. #[test] fn resolve_tie_phase_covers_all_arms() { use crate::decrypt::Phase; @@ -2843,8 +2814,7 @@ mod tests { ); } - // forensic + fills must cover every LBA of every extent EXACTLY once: no - // gap, no overlap. See docs/resolve.md#assert_gapless. + // forensic + fills must cover every LBA of every extent EXACTLY once: no gap, no overlap. fn assert_gapless( extents: &[Extent], forensic: &[(u32, u32, usize, crate::decrypt::Phase)], @@ -2869,7 +2839,6 @@ mod tests { } // BEHAVIOR 3 — gap-fill range arithmetic, exhaustive over segment positions. - // See docs/resolve.md#fill_base_key_gaps_is_gapless_over_every_extent. #[test] fn fill_base_key_gaps_is_gapless_over_every_extent() { use crate::decrypt::Phase::{All, Even, Odd}; @@ -2963,8 +2932,7 @@ mod tests { // ── Fix 1: FMTS phase-probe read-fault vs wrong-key distinction ───────── - // Build a 6144-byte aligned unit of CLEAN MPEG-TS then AACS-encrypt it under - // key. See docs/resolve.md#encrypted_clean_unit. + // Build a 6144-byte aligned unit of CLEAN MPEG-TS then AACS-encrypt it under key. fn encrypted_clean_unit(key: &[u8; 16]) -> Vec<u8> { use crate::aacs::content::ALIGNED_UNIT_LEN; let mut u = vec![0u8; ALIGNED_UNIT_LEN]; @@ -2994,7 +2962,6 @@ mod tests { } // A probe whose EVERY read faults must classify as ReadFault, NOT WrongKey. - // See docs/resolve.md#probe_index_phase_all_faults_is_read_fault_not_wrong_key. #[test] fn probe_index_phase_all_faults_is_read_fault_not_wrong_key() { let segs = vec![a_segment(1)]; @@ -3072,8 +3039,8 @@ mod tests { ); } - // Read-fault TOLERANCE: first same-index segment faults every read, second - // decrypts clean -> probe falls through. See docs/resolve.md#probe_index_phase_falls_through_faulting_segment_to_next. + // Read-fault TOLERANCE: first same-index segment faults every read, second decrypts clean + // -> probe falls through. #[test] fn probe_index_phase_falls_through_faulting_segment_to_next() { use crate::decrypt::Phase; @@ -3111,8 +3078,8 @@ mod tests { // ── Fix 2: resolve_mux_key_map multi-CPS key selection (real ciphertext) ─ - // Tiles a fixed ciphertext unit across each registered extent range, zeros - // elsewhere; low LBAs zero so FMTS misses and multi-CPS runs. See docs/resolve.md#ciphersource. + // Tiles a fixed ciphertext unit across each registered extent range, zeros elsewhere; low + // LBAs zero so FMTS misses and multi-CPS runs. struct CipherSource { units: Vec<(u32, u32, Vec<u8>)>, } @@ -3153,8 +3120,7 @@ mod tests { t } - // pick() must select the pool index that opens the extent's real ciphertext - // (idx 2, not 0). See docs/resolve.md#resolve_mux_key_map_multi_cps_pick_selects_correct_index. + // pick() must select the pool index that opens the extent's real ciphertext (idx 2, not 0). #[test] fn resolve_mux_key_map_multi_cps_pick_selects_correct_index() { let key_a = [0x01u8; 16]; @@ -3187,8 +3153,8 @@ mod tests { ); } - // Fail-loud: no held/fetched key opening a sample must surface DecryptFailed, - // never mis-key. See docs/resolve.md#resolve_mux_key_map_multi_cps_fail_loud_on_absent_key. + // Fail-loud: no held/fetched key opening a sample must surface DecryptFailed, never + // mis-key. #[test] fn resolve_mux_key_map_multi_cps_fail_loud_on_absent_key() { let key_a = [0x01u8; 16]; @@ -3218,8 +3184,7 @@ mod tests { assert_eq!(err.to_string(), expected, "expected DecryptFailed"); } - // KeyFetch cold path: pool missing the key, injected KeyFetch recovers it, map - // succeeds. See docs/resolve.md#resolve_mux_key_map_multi_cps_fetch_recovers_missing_key. + // KeyFetch cold path: pool missing the key, injected KeyFetch recovers it, map succeeds. #[test] fn resolve_mux_key_map_multi_cps_fetch_recovers_missing_key() { let key_a = [0x01u8; 16]; @@ -3269,8 +3234,7 @@ mod tests { /// and every content extent in these tests starts at or above this. const CONTENT_LBA_FLOOR: u32 = 1000; - // CipherSource plus a counter of CONTENT reads, excluding low-LBA UDF metadata - // probes. See docs/resolve.md#countingciphersource. + // CipherSource plus a counter of CONTENT reads, excluding low-LBA UDF metadata probes. struct CountingCipherSource { inner: CipherSource, probes: u32, @@ -3308,8 +3272,8 @@ mod tests { } } - // A second title over the same extent must cost ZERO further reads and resolve - // the SAME index; a different extent must still be sampled. See docs/resolve.md#multi_cps_shared_extent_is_served_from_cache_not_resampled. + // A second title over the same extent must cost ZERO further reads and resolve the SAME + // index; a different extent must still be sampled. #[test] fn multi_cps_shared_extent_is_served_from_cache_not_resampled() { let key_a = [0x01u8; 16]; @@ -3401,7 +3365,6 @@ mod tests { } // A cache hit must equal a full recompute range-for-range (warm vs cold reader). - // See docs/resolve.md#multi_cps_cache_hit_matches_a_full_recompute. #[test] fn multi_cps_cache_hit_matches_a_full_recompute() { let key_a = [0x01u8; 16]; @@ -3478,8 +3441,8 @@ mod tests { ); } - // An extent with no sampleable units inherits the PRECEDING extent's index (a - // per-title fact) and must never be memoised. See docs/resolve.md#multi_cps_inherited_index_is_not_cached. + // An extent with no sampleable units inherits the PRECEDING extent's index (a per-title + // fact) and must never be memoised. #[test] fn multi_cps_inherited_index_is_not_cached() { let key_a = [0x01u8; 16]; @@ -3551,8 +3514,8 @@ mod tests { ); } - // A fail-loud extent must not be memoised: a later retry (key now banked) must - // re-sample and succeed. See docs/resolve.md#multi_cps_failed_extent_is_not_cached. + // A fail-loud extent must not be memoised: a later retry (key now banked) must re-sample + // and succeed. #[test] fn multi_cps_failed_extent_is_not_cached() { let key_a = [0x01u8; 16]; @@ -3605,7 +3568,6 @@ mod tests { // ── Fix 1: read-fault vs genuinely-not-FMTS in resolve_fmts_key_map ────── // Every read is a transient DiscRead fault (marginal live drive). - // See docs/resolve.md#faultsource. struct FaultSource; impl SectorSource for FaultSource { fn capacity_sectors(&self) -> u32 { @@ -3626,8 +3588,8 @@ mod tests { } } - // A transient DiscRead fault reading UDF metadata must PROPAGATE, not be - // swallowed into not-FMTS Ok(None). See docs/resolve.md#resolve_fmts_key_map_read_fault_propagates. + // A transient DiscRead fault reading UDF metadata must PROPAGATE, not be swallowed into + // not-FMTS Ok(None). #[test] fn resolve_fmts_key_map_read_fault_propagates() { let mut reader = FaultSource; @@ -3660,8 +3622,7 @@ mod tests { ); } - // A structurally non-UDF reader is genuinely not FMTS: clean Ok(None), not - // fail-loud. See docs/resolve.md#resolve_fmts_key_map_not_udf_is_clean_none. + // A structurally non-UDF reader is genuinely not FMTS: clean Ok(None), not fail-loud. #[test] fn resolve_fmts_key_map_not_udf_is_clean_none() { // CipherSource with no registered units reads as all zeros everywhere, so @@ -3780,8 +3741,8 @@ mod tests { Probe, } - // Synthetic disc with extra IndividualSegment.tbl records appended; content - // region unchanged. See docs/resolve.md#fmts_disc_with_extra_records. + // Synthetic disc with extra IndividualSegment.tbl records appended; content region + // unchanged. fn fmts_disc_with_extra_records(extra: &[crate::aacs::segment::Segment]) -> FmtsDisc { let mut all = fmts_segments(); all.extend_from_slice(extra); @@ -3796,7 +3757,6 @@ mod tests { } // UDF metadata image alone, with tbl_segs as the segment table. - // See docs/resolve.md#fmtsdiscrebuild_meta--with_forensic_clip. fn rebuild_meta( tbl_segs: &[crate::aacs::segment::Segment], ) -> crate::udf::fixture::MemDisc { @@ -3870,7 +3830,6 @@ mod tests { } // Same disc + a second CPS unit at FMTS_CPS2_LBA (ambiguous "base key"). - // See docs/resolve.md#fmtsdiscwith_second_cps_unit. fn with_second_cps_unit() -> Self { Self { second_cps: true, @@ -3891,7 +3850,6 @@ mod tests { } } - // See docs/resolve.md#fmtsdiscunit_at. fn unit_at(&self, unit_byte: u64) -> Vec<u8> { for s in &self.segs { let sb = s.start_byte(); @@ -3975,8 +3933,8 @@ mod tests { } } - // A KeyFetch whose fmts_indexes counts calls, replying with the full index-key - // set only for a genuine index-1 anchor batch. See docs/resolve.md#counting_fmts_fetch. + // A KeyFetch whose fmts_indexes counts calls, replying with the full index-key set only for + // a genuine index-1 anchor batch. fn counting_fmts_fetch( calls: std::sync::Arc<std::sync::atomic::AtomicUsize>, ) -> crate::sector::KeyFetch { @@ -4009,7 +3967,7 @@ mod tests { } } - // A play-all title over BOTH CPS units. See docs/resolve.md#fmts_two_cps_title. + // A play-all title over BOTH CPS units. fn fmts_two_cps_title() -> DiscTitle { let mut t = DiscTitle::empty(); t.extents = vec![ @@ -4026,7 +3984,6 @@ mod tests { } // The disc's two BASE CPS Unit Keys, in Unit_Key_RO.inf order. - // See docs/resolve.md#fmts_two_cps_keys. fn fmts_two_cps_keys() -> DecryptKeys { DecryptKeys::Aacs { unit_keys: vec![(1, FMTS_BASE_KEY), (2, FMTS_CPS2_KEY)], @@ -4034,8 +3991,7 @@ mod tests { } } - // The non-forensic gap fill must use each LBA's own CPS unit key, not - // hardcode pool slot 0. See docs/resolve.md#fmts_gap_fill_uses_each_lbas_own_cps_unit_key_not_pool_slot_zero. + // The non-forensic gap fill must use each LBA's own CPS unit key, not hardcode pool slot 0. #[test] fn fmts_gap_fill_uses_each_lbas_own_cps_unit_key_not_pool_slot_zero() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -4100,8 +4056,8 @@ mod tests { } } - // The FMTS branch must resolve UDF walk + probes + key service ONCE per disc, - // not once per playlist. See docs/resolve.md#fmts_index_keys_resolved_once_per_disc_not_once_per_title. + // The FMTS branch must resolve UDF walk + probes + key service ONCE per disc, not once per + // playlist. #[test] fn fmts_index_keys_resolved_once_per_disc_not_once_per_title() { use std::sync::atomic::Ordering; @@ -4187,8 +4143,7 @@ mod tests { ); } - // Same three titles via a SHARED memo must equal a FRESH-memo-per-title - // recomputation. See docs/resolve.md#fmts_memoised_map_equals_per_title_recomputation. + // Same three titles via a SHARED memo must equal a FRESH-memo-per-title recomputation. #[test] fn fmts_memoised_map_equals_per_title_recomputation() { let titles = [ @@ -4271,7 +4226,6 @@ mod tests { } // A DIFFERENT extent list is a genuine memo miss and must re-probe. - // See docs/resolve.md#fmts_different_extent_list_is_a_miss_and_reprobes. #[test] fn fmts_different_extent_list_is_a_miss_and_reprobes() { use std::sync::atomic::Ordering; @@ -4321,8 +4275,8 @@ mod tests { ); } - // A read-faulted phase (defaulted to Phase::All) must NOT be memoised; the next - // title re-probes. See docs/resolve.md#fmts_read_faulted_phase_is_not_memoised. + // A read-faulted phase (defaulted to Phase::All) must NOT be memoised; the next title + // re-probes. #[test] fn fmts_read_faulted_phase_is_not_memoised() { use std::sync::atomic::Ordering; @@ -4380,8 +4334,7 @@ mod tests { ); } - // The UDF walk deciding FMTS-or-not must be attempted ONCE per disc, not once - // per playlist. See docs/resolve.md#non_fmts_disc_walks_the_filesystem_once_for_every_title. + // The UDF walk deciding FMTS-or-not must be attempted ONCE per disc, not once per playlist. #[test] fn non_fmts_disc_walks_the_filesystem_once_for_every_title() { let key_a = [0x01u8; 16]; @@ -4432,8 +4385,7 @@ mod tests { ); } - // Halt must stay responsive even when both FMTS memos are warm and a title does - // no I/O. See docs/resolve.md#fmts_memoised_title_still_honors_halt. + // Halt must stay responsive even when both FMTS memos are warm and a title does no I/O. #[test] fn fmts_memoised_title_still_honors_halt() { use crate::halt::Halt; @@ -4471,8 +4423,8 @@ mod tests { assert!(crate::error::is_halt(&err), "expected Halted, got: {err}"); } - // The single-CPS short-circuit must depend on the count of BASE CPS Unit Keys, - // NOT whole-pool length (FMTS appends forensic keys). See docs/resolve.md#single_cps_short_circuit_survives_a_forensic_title_resolving_first. + // The single-CPS short-circuit must depend on the count of BASE CPS Unit Keys, NOT + // whole-pool length (FMTS appends forensic keys). #[test] fn single_cps_short_circuit_survives_a_forensic_title_resolving_first() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -4535,8 +4487,8 @@ mod tests { /// trailer's length and the two candidate LBAs are unambiguous. const TRAILER_SECTORS: u32 = 300; - // Forensic segment SPNs live in the FORENSIC CLIP's byte space, not the title's - // first extent (a trailer-first play-all playlist). See docs/resolve.md#forensic_segments_anchor_to_the_forensic_clip_not_the_titles_first_extent. + // Forensic segment SPNs live in the FORENSIC CLIP's byte space, not the title's first + // extent (a trailer-first play-all playlist). #[test] fn forensic_segments_anchor_to_the_forensic_clip_not_the_titles_first_extent() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -4600,7 +4552,7 @@ mod tests { } // A disc with a segment table but no identifiable forensic clip must fail LOUD - // (FmtsKeyMissing), never guess an anchor. See docs/resolve.md#unidentifiable_forensic_clip_fails_loud_rather_than_guessing_an_anchor. + // (FmtsKeyMissing), never guess an anchor. #[test] fn unidentifiable_forensic_clip_fails_loud_rather_than_guessing_an_anchor() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -4632,8 +4584,7 @@ mod tests { ); } - // The base-CPS count must exclude banked forensic index keys (tagged - // FMTS_POOL_TAG_BASE+n). See docs/resolve.md#single_base_key_slot_counts_cps_units_not_banked_forensic_keys. + // The base-CPS count must exclude banked forensic index keys (tagged FMTS_POOL_TAG_BASE+n). #[test] fn single_base_key_slot_counts_cps_units_not_banked_forensic_keys() { let base = [0x01u8; 16]; @@ -4669,8 +4620,8 @@ mod tests { ); } - // Forensic ranges arrive in table RECORD order, not LBA order; the gap walk's - // forward sweep needs them sorted. See docs/resolve.md#fill_base_key_gaps_sorts_cuts_that_arrive_in_table_order_not_lba_order. + // Forensic ranges arrive in table RECORD order, not LBA order; the gap walk's forward sweep + // needs them sorted. #[test] fn fill_base_key_gaps_sorts_cuts_that_arrive_in_table_order_not_lba_order() { use crate::decrypt::Phase::{All, Even, Odd}; @@ -4692,7 +4643,6 @@ mod tests { } // Records every (lba, count) requested and serves a caller-chosen aligned unit. - // See docs/resolve.md#recordingsource. struct RecordingSource { reads: Vec<(u32, u16)>, unit: Vec<u8>, @@ -4716,7 +4666,6 @@ mod tests { } // sample_encrypted_units: 8 probes at total*p/9 for p in 1..=8, pinned exactly. - // See docs/resolve.md#sample_encrypted_units_probes_eight_aligned_units_spread_across_the_extent. #[test] fn sample_encrypted_units_probes_eight_aligned_units_spread_across_the_extent() { let unit = encrypted_clean_unit(&[0x5Au8; 16]); @@ -4748,8 +4697,7 @@ mod tests { ); } - // Only genuinely AACS-encrypted units come back; a clear extent yields NO - // samples. See docs/resolve.md#sample_encrypted_units_drops_clear_units_and_reads_nothing_below_one_unit. + // Only genuinely AACS-encrypted units come back; a clear extent yields NO samples. #[test] fn sample_encrypted_units_drops_clear_units_and_reads_nothing_below_one_unit() { // A clear unit: the AACS scrambling bits in byte 0 are zero. @@ -4776,8 +4724,8 @@ mod tests { assert!(got.is_empty()); } - // pick_pool_slot answers "which of THESE slots, in THIS order, opens the - // extent" — both restriction and order are load-bearing. See docs/resolve.md#pick_pool_slot_honours_the_caller_s_slot_list_and_its_order. + // pick_pool_slot answers "which of THESE slots, in THIS order, opens the extent" — both + // restriction and order are load-bearing. #[test] fn pick_pool_slot_honours_the_caller_s_slot_list_and_its_order() { let k0 = [0x01u8; 16]; @@ -4815,7 +4763,6 @@ mod tests { } // Extents are [start_lba, start_lba + sector_count) — half open, both ends. - // See docs/resolve.md#extents_overlap_is_half_open_at_both_ends. #[test] fn extents_overlap_is_half_open_at_both_ends() { let at = |start_lba, sector_count| { @@ -4868,8 +4815,7 @@ mod tests { std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string() } - // Resolve the synthetic FMTS disc with extra bogus records appended, return - // the error text. See docs/resolve.md#fmts_resolve_err_with. + // Resolve the synthetic FMTS disc with extra bogus records appended, return the error text. fn fmts_resolve_err_with(extra: &[crate::aacs::segment::Segment]) -> String { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let fetch = counting_fmts_fetch(calls); @@ -4890,8 +4836,7 @@ mod tests { .to_string() } - // Control: the SAME resolve with no extra records succeeds (guards the four - // tests below). See docs/resolve.md#fmts_baseline_table_resolves_so_the_unmappable_record_tests_mean_something. + // Control: the SAME resolve with no extra records succeeds (guards the four tests below). #[test] fn fmts_baseline_table_resolves_so_the_unmappable_record_tests_mean_something() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -4922,7 +4867,6 @@ mod tests { } // Arm 1 — an INVERTED record (start_spn > end_spn) is refused, not base-keyed. - // See docs/resolve.md#fmts_inverted_segment_record_aborts_rather_than_base_keying_the_hole. #[test] fn fmts_inverted_segment_record_aborts_rather_than_base_keying_the_hole() { let bad = crate::aacs::segment::Segment { @@ -4934,7 +4878,6 @@ mod tests { } // Arm 2 — a record whose forensic INDEX has no key must abort, not base-key. - // See docs/resolve.md#fmts_record_with_no_index_key_aborts_rather_than_base_keying_the_hole. #[test] fn fmts_record_with_no_index_key_aborts_rather_than_base_keying_the_hole() { let bad = crate::aacs::segment::Segment { @@ -4946,7 +4889,6 @@ mod tests { } // Arm 3 — record START addressable but END runs past the clip's last byte. - // See docs/resolve.md#fmts_record_running_past_the_clips_end_aborts_rather_than_base_keying_it. #[test] fn fmts_record_running_past_the_clips_end_aborts_rather_than_base_keying_it() { // The clip is FMTS_CONTENT_SECTORS * 2048 bytes = 21_333 whole packets. @@ -4969,8 +4911,8 @@ mod tests { assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); } - // Arm 4 — a record off the aligned-unit grid (sector-crossing count disagrees - // with byte-length count) must abort. See docs/resolve.md#fmts_record_off_the_aligned_unit_grid_aborts_rather_than_spanning_wrongly. + // Arm 4 — a record off the aligned-unit grid (sector-crossing count disagrees with + // byte-length count) must abort. #[test] fn fmts_record_off_the_aligned_unit_grid_aborts_rather_than_spanning_wrongly() { let bad = crate::aacs::segment::Segment { @@ -4981,8 +4923,8 @@ mod tests { assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); } - // The multi-CPS inheritance chain must run THROUGH a cache hit (hit arm feeds - // last_idx), else a later unsampleable extent inherits slot 0. See docs/resolve.md#multi_cps_cache_hit_still_feeds_the_next_extents_inheritance. + // The multi-CPS inheritance chain must run THROUGH a cache hit (hit arm feeds last_idx), + // else a later unsampleable extent inherits slot 0. #[test] fn multi_cps_cache_hit_still_feeds_the_next_extents_inheritance() { let key_a = [0x01u8; 16]; @@ -5054,8 +4996,8 @@ mod tests { ); } - // FMTS gap fill on a multi-CPS disc: a clear tail must inherit the preceding - // extent's CPS unit, not base_slots[0]. See docs/resolve.md#fmts_multi_cps_gap_fill_carries_the_preceding_extents_cps_unit_to_a_clear_tail. + // FMTS gap fill on a multi-CPS disc: a clear tail must inherit the preceding extent's CPS + // unit, not base_slots[0]. #[test] fn fmts_multi_cps_gap_fill_carries_the_preceding_extents_cps_unit_to_a_clear_tail() { const CLEAR_LBA: u32 = FMTS_CPS2_LBA + FMTS_CPS2_SECTORS; @@ -5097,8 +5039,8 @@ mod tests { } } - // FMTS gap fill samples extents through the same per-disc CpsUnitCache; a - // second title over the same extents costs ZERO further reads. See docs/resolve.md#fmts_multi_cps_gap_fill_samples_each_extent_once_per_disc. + // FMTS gap fill samples extents through the same per-disc CpsUnitCache; a second title over + // the same extents costs ZERO further reads. #[test] fn fmts_multi_cps_gap_fill_samples_each_extent_once_per_disc() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -5163,8 +5105,8 @@ mod tests { std::io::Error::from(crate::error::Error::Halted).to_string() } - // Stop lands during the UDF walk; the anchor loop's own poll must catch it so - // no CONTENT sector is ever requested. See docs/resolve.md#fmts_stop_during_the_udf_walk_touches_no_content_sector. + // Stop lands during the UDF walk; the anchor loop's own poll must catch it so no CONTENT + // sector is ever requested. #[test] fn fmts_stop_during_the_udf_walk_touches_no_content_sector() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -5202,8 +5144,8 @@ mod tests { ); } - // Stop lands during the anchor batch; the PHASE loop's poll must catch it - // before probing index 1's parity. See docs/resolve.md#fmts_stop_during_the_anchor_batch_stops_before_the_phase_probes. + // Stop lands during the anchor batch; the PHASE loop's poll must catch it before probing + // index 1's parity. #[test] fn fmts_stop_during_the_anchor_batch_stops_before_the_phase_probes() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); diff --git a/src/mux/resync.rs b/src/mux/resync.rs index 91c52030..ad8cfa19 100644 --- a/src/mux/resync.rs +++ b/src/mux/resync.rs @@ -5,9 +5,8 @@ //! frame would fault a decoder, so this gate drops forward past a gap on a //! video track until the next IRAP/IDR keyframe and resumes there. //! -//! Audio/subtitle frames are independently decodable, so the gate is a -//! no-op for them; TrueHD/MLP is the one exception and is handled in -//! `codec::truehd` instead. See docs/resync.md for the full rationale. +//! Audio/subtitle frames are independently decodable, so the gate is a no-op for them; +//! TrueHD/MLP is the one exception and is handled in `codec::truehd` instead. /// Per-track keyframe-resync state. One gate per elementary stream; a video /// track's gate stays "armed" from a discontinuity until the next keyframe. @@ -37,9 +36,8 @@ impl ResyncGate { } } - // Decide whether a parsed frame should be EMITTED (`true`) or DROPPED - // (`false`). Video arms on discontinuity, drops until the next keyframe. - // See docs/resync.md — `admit` parameters and behavior. + // Decide whether a parsed frame should be EMITTED (`true`) or DROPPED (`false`). Video arms + // on discontinuity, drops until the next keyframe. pub(crate) fn admit(&mut self, is_video: bool, discontinuity: bool, keyframe: bool) -> bool { if !is_video { return true; @@ -83,9 +81,8 @@ impl ResyncGate { #[cfg(test)] mod tests { - // `dropped` is per-run, `dropped_total` cumulative; only the latter can - // report loss on a gap that resolves. See docs/resync.md — test - // `a_resolved_gap_still_reports_its_dropped_frames`. + // `dropped` is per-run, `dropped_total` cumulative; only the latter can report loss on a + // gap that resolves. #[test] fn a_resolved_gap_still_reports_its_dropped_frames() { let mut g = ResyncGate::new(); diff --git a/src/mux/select.rs b/src/mux/select.rs index 1686bd5e..2e69a8d5 100644 --- a/src/mux/select.rs +++ b/src/mux/select.rs @@ -4,8 +4,6 @@ //! BEFORE the mux path finalizes the title, so track headers, `codec_privates`, //! PID routing, and frame emission all follow from the pruned list by //! construction. Language-agnostic: PIDs, not languages. -//! -//! See docs/mux-select.md — declaration-driven pipeline rationale. use crate::disc::{DiscTitle, Stream}; use crate::error::{Error, Result}; @@ -39,7 +37,6 @@ impl StreamSelection { matches!(self.audio, PidFilter::All) && matches!(self.subtitle, PidFilter::All) } - // See docs/mux-select.md — codec_privates lockstep & fail-loud rationale. /// Prune `title.streams` in place: keep every [`Stream::Video`] /// unconditionally; keep an [`Stream::Audio`]/[`Stream::Subtitle`] iff its /// PID passes the corresponding [`PidFilter`]; drop the rest. Declared @@ -302,9 +299,9 @@ mod tests { ); } - // codec_privates longer than streams must still prune in lockstep by index - // (regression: a trailing extra entry once skipped the prune, misattaching - // codec-private to the wrong track). See docs/mux-select.md. + // codec_privates longer than streams must still prune in lockstep by index (regression: a + // trailing extra entry once skipped the prune, misattaching codec-private to the wrong + // track). #[test] fn apply_prunes_codec_privates_even_when_length_does_not_match_streams() { let mut t = title(); @@ -335,9 +332,8 @@ mod tests { "the two positional vecs must be aligned after apply()" ); } - // A PID in the wrong class's filter must fail loud, not silently vanish - // (validation once scanned both classes, letting it pass then get dropped - // by `keeps`). See docs/mux-select.md. + // A PID in the wrong class's filter must fail loud, not silently vanish (validation once + // scanned both classes, letting it pass then get dropped by `keeps`). #[test] fn a_pid_listed_in_the_wrong_class_filter_is_rejected() { let mut t = title(); diff --git a/src/mux/timeline.rs b/src/mux/timeline.rs index eb53dd78..9c033919 100644 --- a/src/mux/timeline.rs +++ b/src/mux/timeline.rs @@ -1,23 +1,20 @@ //! Shared clip-boundary timeline corrector. //! -//! A BD/UHD title's clips are read as one concatenated sector stream, so the -//! source PES PTS does not run continuously across a clip join. [`SeamPlan`] -//! places frames exactly from the playlist's marks when present; -//! [`TimelineContinuity::adjust`] infers seams from PTS jumps otherwise, and -//! [`TimelineContinuity::map`] picks between them so every muxer/sink shares -//! one correction path. See docs/mux-timeline.md for the full rationale. +//! A BD/UHD title's clips are read as one concatenated sector stream, so the source PES PTS +//! does not run continuously across a clip join. [`SeamPlan`] places frames exactly from the +//! playlist's marks when present; [`TimelineContinuity::adjust`] infers seams from PTS jumps +//! otherwise, and [`TimelineContinuity::map`] picks between them so every muxer/sink shares one +//! correction path. -// A backward PTS step larger than this is a clip-boundary discontinuity -// (source PES PTS reset), NOT B-frame reorder (HEVC/H.264 tops out ~16 -// frames, <1s @24fps). See docs/mux-timeline.md#discontinuity_backstep_ns. +// A backward PTS step larger than this is a clip-boundary discontinuity (source PES PTS reset), +// NOT B-frame reorder (HEVC/H.264 tops out ~16 frames, <1s @24fps). pub(crate) const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000; /// Sub-frame gap inserted after a rebased discontinuity so the first frame of /// the new clip lands strictly after the previous timeline high (1 ms). pub(crate) const DISCONTINUITY_GAP_NS: i64 = 1_000_000; -// How close a frame's PTS must be to a clip's IN mark to be recognised as -// that clip's opening frame, vs. the previous clip's overlapping tail. See -// docs/mux-timeline.md#clip_start_tolerance_ns. +// How close a frame's PTS must be to a clip's IN mark to be recognised as that clip's opening +// frame, vs. the previous clip's overlapping tail. pub(crate) const CLIP_START_TOLERANCE_NS: i64 = 250_000_000; /// MPLS 45 kHz tick → nanoseconds. PlayItem `in_time`/`out_time` are 45 kHz @@ -38,28 +35,23 @@ pub(crate) struct SeamClip { /// Added to a raw PTS inside this clip to place it on the output timeline. /// Equals (sum of every earlier clip's playable duration) − `in_ns`. pub(crate) offset_ns: i64, - // Byte range this clip occupies in the title's feed, when known. A byte - // offset resolves an overlap that timestamps alone cannot. See - // docs/mux-timeline.md#seamclipfeed_span. + // Byte range this clip occupies in the title's feed, when known. A byte offset resolves an + // overlap that timestamps alone cannot. pub(crate) feed_span: Option<(u64, u64)>, } -// The playlist's own answer to "where does each clip belong on the -// timeline": a seamless-branching title's PlayItems can overlap or skip in -// the shared clock. See docs/mux-timeline.md#seamplan-struct-doc. +// The playlist's own answer to "where does each clip belong on the timeline": a +// seamless-branching title's PlayItems can overlap or skip in the shared clock. pub(crate) struct SeamPlan { - // Whether the per-clip feed spans tile the feed contiguously from 0, so - // a byte offset can be trusted to identify a clip. If not, provenance is - // disabled. See docs/mux-timeline.md#seamplanspans_trusted. + // Whether the per-clip feed spans tile the feed contiguously from 0, so a byte offset can + // be trusted to identify a clip. If not, provenance is disabled. spans_trusted: bool, clips: Vec<SeamClip>, - // Frames dropped (per track) for falling outside every clip's marks. - // Counted so an unexpected volume is visible instead of silent. See - // docs/mux-timeline.md#seamplandropped. + // Frames dropped (per track) for falling outside every clip's marks. Counted so an + // unexpected volume is visible instead of silent. dropped: Vec<u64>, - // Per-track position: (clip index, last raw PTS seen). Each track - // crosses a join on its OWN frame, since overlap tails arrive after the - // next clip's video. See docs/mux-timeline.md#seamplancursors. + // Per-track position: (clip index, last raw PTS seen). Each track crosses a join on its OWN + // frame, since overlap tails arrive after the next clip's video. cursors: Vec<TrackPos>, } @@ -79,9 +71,8 @@ struct TrackPos { } impl SeamPlan { - // Build a plan, or `None` when there's nothing to place (no clips, or - // unusable marks). A single clip still gets a plan, to trim to - // `[in, out]`. See docs/mux-timeline.md#seamplanfrom_clips. + // Build a plan, or `None` when there's nothing to place (no clips, or unusable marks). A + // single clip still gets a plan, to trim to `[in, out]`. pub(crate) fn from_clips(clips: &[crate::disc::Clip]) -> Option<Self> { if clips.is_empty() { return None; @@ -198,9 +189,8 @@ impl SeamPlan { .fold(0i64, |a, b| a.saturating_add(b)) } - // Which clip owns feed byte `b`, by BINARY SEARCH (spans tile the feed - // contiguously, so a scan would be ~900 comparisons per frame on a large - // disc). See docs/mux-timeline.md#seamplanclip_at_byte. + // Which clip owns feed byte `b`, by BINARY SEARCH (spans tile the feed contiguously, so a + // scan would be ~900 comparisons per frame on a large disc). fn clip_at_byte(&self, b: u64) -> Option<usize> { let i = self .clips @@ -219,9 +209,8 @@ impl SeamPlan { Some(first) } - // Pick the member of a shared-span run whose marks contain `raw_ns` - // (several PlayItems can reference one file). Falls back to `first`. - // See docs/mux-timeline.md#seamplanclip_in_run_for. + // Pick the member of a shared-span run whose marks contain `raw_ns` (several PlayItems can + // reference one file). Falls back to `first`. fn clip_in_run_for(&self, first: usize, raw_ns: i64) -> usize { let span = self.clips[first].feed_span; let mut i = first; @@ -406,15 +395,13 @@ impl SeamPlan { } } -// Global timeline corrector: holds a SeamPlan when usable, else falls back to -// PTS-jump inference. Only the VIDEO track drives epoch decisions. See -// docs/mux-timeline.md#timelinecontinuity-struct-doc. +// Global timeline corrector: holds a SeamPlan when usable, else falls back to PTS-jump +// inference. Only the VIDEO track drives epoch decisions. pub(crate) struct TimelineContinuity { /// Offset (ns) added to raw PTS for the CURRENT epoch. pub(crate) offset_ns: i64, - // Offset (ns) of the immediately previous epoch, used to remap a - // non-video tail straggler at a boundary. See - // docs/mux-timeline.md#timelinecontinuityprev_offset_ns. + // Offset (ns) of the immediately previous epoch, used to remap a non-video tail straggler + // at a boundary. pub(crate) prev_offset_ns: i64, /// Highest adjusted VIDEO PTS (ns) accepted onto the timeline so far — the /// running frontier. `None` until the first video frame. Only video advances @@ -424,18 +411,15 @@ pub(crate) struct TimelineContinuity { /// marks are known and are used verbatim; absent = fall back to inferring /// seams from PTS jumps, which is all any non-BD source has ever had. pub(crate) seams: Option<SeamPlan>, - // Every epoch already left behind, oldest first, as (offset, frontier - // when it closed) — a single prev_offset_ns can't name a straggler's - // epoch. See docs/mux-timeline.md#timelinecontinuityepoch_offsets. + // Every epoch already left behind, oldest first, as (offset, frontier when it closed) — a + // single prev_offset_ns can't name a straggler's epoch. epoch_offsets: Vec<(i64, i64)>, - // Last raw PTS seen per track, for spotting a track's own discontinuity - // (distinct from the shared frontier). See - // docs/mux-timeline.md#timelinecontinuitylast_raw_ns. + // Last raw PTS seen per track, for spotting a track's own discontinuity (distinct from the + // shared frontier). last_raw_ns: Vec<Option<i64>>, - /// Per-track provisional offset for frames arriving before the video - /// frame that opens their epoch, tagged with the epoch sequence it was - /// captured in (see `epoch_seq`). Never written to offset_ns/high_ns and - /// never retires an epoch. See docs/mux-timeline.md#timelinecontinuityprovisional. + /// Per-track provisional offset for frames arriving before the video frame that opens their + /// epoch, tagged with the epoch sequence it was captured in (see `epoch_seq`). Never + /// written to offset_ns/high_ns and never retires an epoch. provisional: Vec<Option<(u64, i64)>>, /// Monotonic epoch counter, bumped once per `open_epoch`. Used as the epoch /// IDENTITY a provisional is tagged with, instead of `epoch_offsets.len()`: @@ -464,8 +448,8 @@ impl TimelineContinuity { } } - // Corrector driven by a title's PlayItem marks where they exist, else - // `Self::new`'s inference. See docs/mux-timeline.md#timelinecontinuitywith_clips. + // Corrector driven by a title's PlayItem marks where they exist, else `Self::new`'s + // inference. pub(crate) fn with_clips( clips: &[crate::disc::Clip], content_format: crate::disc::ContentFormat, @@ -489,8 +473,8 @@ impl TimelineContinuity { } } - // Total frames dropped for falling outside the playlist's clip marks. - // Zero without a seam plan. See docs/mux-timeline.md#timelinecontinuitydropped_total. + // Total frames dropped for falling outside the playlist's clip marks. Zero without a seam + // plan. pub(crate) fn dropped_total(&self) -> u64 { self.seams.as_ref().map_or(0, |p| p.dropped_total()) } @@ -510,8 +494,8 @@ impl TimelineContinuity { }) } - // Map a raw PES PTS onto the output timeline, or `None` to drop the frame - // (only ever happens under a SeamPlan). See docs/mux-timeline.md#timelinecontinuitymap. + // Map a raw PES PTS onto the output timeline, or `None` to drop the frame (only ever + // happens under a SeamPlan). pub(crate) fn map( &mut self, raw_pts_ns: i64, @@ -542,9 +526,8 @@ impl TimelineContinuity { Some(self.adjust(raw_pts_ns, drives_epoch, track)) } - // The offset a passive frame should ride: normally the current epoch's, - // unless the frame arrived ahead of the video that opens its epoch. See - // docs/mux-timeline.md#timelinecontinuitypassive_offset. + // The offset a passive frame should ride: normally the current epoch's, unless the frame + // arrived ahead of the video that opens its epoch. fn passive_offset(&mut self, track: usize, raw_pts_ns: i64) -> i64 { if self.last_raw_ns.len() <= track { self.last_raw_ns.resize(track + 1, None); @@ -581,9 +564,8 @@ impl TimelineContinuity { effective } - // Retire the current epoch and open a new one continuing just after the - // frontier, recording where this epoch closed for straggler lookups. See - // docs/mux-timeline.md#timelinecontinuityopen_epoch. + // Retire the current epoch and open a new one continuing just after the frontier, recording + // where this epoch closed for straggler lookups. fn open_epoch(&mut self, high: i64, mapped_now: i64) { self.prev_offset_ns = self.offset_ns; if self.epoch_offsets.len() == MAX_EPOCHS { @@ -599,9 +581,8 @@ impl TimelineContinuity { self.offset_ns = self.offset_ns.saturating_add(bump); } - // The offset of the epoch a straggler actually belongs to: the retained - // epoch that lands it CLOSEST BELOW its own frontier. See - // docs/mux-timeline.md#timelinecontinuitystraggler_offset. + // The offset of the epoch a straggler actually belongs to: the retained epoch that lands it + // CLOSEST BELOW its own frontier. fn straggler_offset(&self, raw_pts_ns: i64) -> Option<i64> { self.epoch_offsets .iter() @@ -615,9 +596,8 @@ impl TimelineContinuity { .max() } - // Map a raw PES PTS (ns) onto the continuous timeline. `drives_epoch` is - // true only for the primary video track. See - // docs/mux-timeline.md#timelinecontinuityadjust. + // Map a raw PES PTS (ns) onto the continuous timeline. `drives_epoch` is true only for the + // primary video track. pub(crate) fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool, track: usize) -> i64 { // Passive track: ride the current epoch's offset. Never advance the // frontier and never open an epoch — these tracks each run on their own @@ -674,9 +654,8 @@ mod tests { tc.adjust(p, false, 1) } - // Characterization of the BUG: two clips concatenated with a PTS reset - // at the boundary must come out monotonic and continuous. See - // docs/mux-timeline.md#continuity_rebases_clip_boundary_reset. + // Characterization of the BUG: two clips concatenated with a PTS reset at the boundary must + // come out monotonic and continuous. #[test] fn continuity_rebases_clip_boundary_reset() { // Clip1 video rising to 10s, then clip2 RESETS near 0 — non-seamless. @@ -729,9 +708,8 @@ mod tests { assert_eq!(tc.offset_ns, 0, "no rebase on forward progression"); } - // The output timeline must never go BACKWARDS at a clip join. Audit - // finding, measured against the real 00801.mpls marks. See - // docs/mux-timeline.md#a_clip_join_never_rewinds_the_output_timeline. + // The output timeline must never go BACKWARDS at a clip join. Audit finding, measured + // against the real 00801.mpls marks. #[test] fn a_clip_join_never_rewinds_the_output_timeline() { let clips = seamless_branching_clips(); @@ -757,9 +735,8 @@ mod tests { } } - // A glitched PTS must not strand a track on a later clip forever. Audit - // finding against the large-backstep branch. See - // docs/mux-timeline.md#a_glitched_pts_does_not_strand_a_track_on_a_later_clip. + // A glitched PTS must not strand a track on a later clip forever. Audit finding against the + // large-backstep branch. #[test] fn a_glitched_pts_does_not_strand_a_track_on_a_later_clip() { let clips = seamless_branching_clips(); @@ -785,9 +762,8 @@ mod tests { ); } - // The three placements audit round 7 enumerated. A track's output never - // runs backwards, and a bad frame never strands the cursor. See - // docs/mux-timeline.md#output_never_rewinds_and_a_bad_frame_never_strands. + // The three placements audit round 7 enumerated. A track's output never runs backwards, and + // a bad frame never strands the cursor. #[test] fn output_never_rewinds_and_a_bad_frame_never_strands() { let clips = seamless_branching_clips(); @@ -885,9 +861,8 @@ mod tests { ); } - // Every track of a clip lives in the SAME stream file, so provenance - // makes video, audio and subtitles agree by construction. See - // docs/mux-timeline.md#all_tracks_of_one_clip_agree_under_provenance. + // Every track of a clip lives in the SAME stream file, so provenance makes video, audio and + // subtitles agree by construction. #[test] fn all_tracks_of_one_clip_agree_under_provenance() { let clips = clips_with_spans(); @@ -945,9 +920,8 @@ mod tests { ); } - // A clip FILE referenced by two adjacent PlayItems shares one feed span - // but each carries its OWN marks; both halves must be kept. See - // docs/mux-timeline.md#a_clip_split_across_two_play_items_keeps_both_halves. + // A clip FILE referenced by two adjacent PlayItems shares one feed span but each carries + // its OWN marks; both halves must be kept. #[test] fn a_clip_split_across_two_play_items_keeps_both_halves() { // One file, one span. Two PlayItems: [0s,10s) then [10s,20s). @@ -1000,9 +974,8 @@ mod tests { ); } - // A source that stamps no provenance (a mkv:// remux) must still work by - // falling back to the mark heuristics. See - // docs/mux-timeline.md#no_provenance_still_places_by_marks. + // A source that stamps no provenance (a mkv:// remux) must still work by falling back to + // the mark heuristics. #[test] fn no_provenance_still_places_by_marks() { let clips = clips_with_spans(); @@ -1031,9 +1004,8 @@ mod tests { ); } - // On frames that are NOT ambiguous, provenance and the mark heuristics - // must give the SAME answer — the strongest available cross-check. See - // docs/mux-timeline.md#provenance_agrees_with_marks_wherever_marks_are_unambiguous. + // On frames that are NOT ambiguous, provenance and the mark heuristics must give the SAME + // answer — the strongest available cross-check. #[test] fn provenance_agrees_with_marks_wherever_marks_are_unambiguous() { let clips = clips_with_spans(); @@ -1072,9 +1044,8 @@ mod tests { ); } - // Walk a whole title through the plan with provenance: output never - // moves backwards, and the total span matches the declared duration. See - // docs/mux-timeline.md#a_full_pass_over_the_real_table_is_monotonic_and_totals_correctly. + // Walk a whole title through the plan with provenance: output never moves backwards, and + // the total span matches the declared duration. #[test] fn a_full_pass_over_the_real_table_is_monotonic_and_totals_correctly() { let clips = clips_with_spans(); @@ -1174,9 +1145,8 @@ mod tests { ); } - // A title whose PlayItems all reference ONE clip file: spans are - // TRUSTED but carry no info on which PlayItem a byte belongs to. See - // docs/mux-timeline.md#one_clip_file_behind_every_play_item_is_not_distinguishable_by_byte. + // A title whose PlayItems all reference ONE clip file: spans are TRUSTED but carry no info + // on which PlayItem a byte belongs to. #[test] fn one_clip_file_behind_every_play_item_is_not_distinguishable_by_byte() { const N: u32 = 8; @@ -1223,9 +1193,8 @@ mod tests { ); } - // Marks that do not advance across the title are normal (each clip has - // its own STC) and are PLACED, since every track carries a byte offset. - // See docs/mux-timeline.md#marks_that_do_not_advance_are_placed_by_provenance. + // Marks that do not advance across the title are normal (each clip has its own STC) and are + // PLACED, since every track carries a byte offset. #[test] fn marks_that_do_not_advance_are_placed_by_provenance() { const N: u32 = 4; @@ -1337,8 +1306,7 @@ mod tests { .collect() } - // The plan's total must equal the title's declared duration. See - // docs/mux-timeline.md#seam_plan_total_matches_the_declared_duration. + // The plan's total must equal the title's declared duration. #[test] fn seam_plan_total_matches_the_declared_duration() { let plan = SeamPlan::from_clips(&seamless_branching_clips()).expect("plan"); @@ -1382,8 +1350,7 @@ mod tests { ); } - // The 9.174 s forward skip between clip 2 and clip 3 must vanish. See - // docs/mux-timeline.md#seam_plan_closes_the_forward_skip. + // The 9.174 s forward skip between clip 2 and clip 3 must vanish. #[test] fn seam_plan_closes_the_forward_skip() { let clips = seamless_branching_clips(); @@ -1402,8 +1369,7 @@ mod tests { ); } - // The 1.79 s overlap at seam 1 must JOIN cleanly, not rewind the - // timeline. See docs/mux-timeline.md#seam_plan_joins_an_overlap_without_rewinding. + // The 1.79 s overlap at seam 1 must JOIN cleanly, not rewind the timeline. #[test] fn seam_plan_joins_an_overlap_without_rewinding() { let clips = seamless_branching_clips(); @@ -1454,8 +1420,7 @@ mod tests { assert_eq!(placed, expected, "straggler must ride clip 0's offset"); } - // `map()`'s seam-plan branch (the frontier/offset bookkeeping glue) was - // untested. See docs/mux-timeline.md#map_under_a_seam_plan_tracks_offset_and_frontier. + // `map()`'s seam-plan branch (the frontier/offset bookkeeping glue) was untested. #[test] fn map_under_a_seam_plan_tracks_offset_and_frontier() { let clips = seamless_branching_clips(); @@ -1516,9 +1481,8 @@ mod tests { ); } - // A SPARSE passive track crosses even when its first frame after the - // join lands well past the mark. See - // docs/mux-timeline.md#a_sparse_passive_track_crosses_late. + // A SPARSE passive track crosses even when its first frame after the join lands well past + // the mark. #[test] fn a_sparse_passive_track_crosses_late() { let clips = seamless_branching_clips(); @@ -1580,8 +1544,7 @@ mod tests { ); } - // Each track crosses a join on its OWN frame (regression for a shared - // cursor). See docs/mux-timeline.md#each_track_crosses_a_join_on_its_own_frame. + // Each track crosses a join on its OWN frame (regression for a shared cursor). #[test] fn each_track_crosses_a_join_on_its_own_frame() { let clips = seamless_branching_clips(); @@ -1621,8 +1584,7 @@ mod tests { ); } - // Only Blu-ray gets a mark-driven plan: HD-DVD/DVD marks are not PES-clock - // positions. See docs/mux-timeline.md#only_blu_ray_gets_a_mark_driven_plan. + // Only Blu-ray gets a mark-driven plan: HD-DVD/DVD marks are not PES-clock positions. #[test] fn only_blu_ray_gets_a_mark_driven_plan() { let clips = seamless_branching_clips(); @@ -1662,9 +1624,8 @@ mod tests { ); } - // A second VIDEO track (a Dolby Vision EL) must get the reorder-safe - // crossing window even though it does not drive epochs. See - // docs/mux-timeline.md#a_second_video_track_keeps_the_reorder_safe_window. + // A second VIDEO track (a Dolby Vision EL) must get the reorder-safe crossing window even + // though it does not drive epochs. #[test] fn a_second_video_track_keeps_the_reorder_safe_window() { let clips = seamless_branching_clips(); @@ -1688,9 +1649,8 @@ mod tests { ); } - // A clip table that is not one advancing clock must fall back to - // inference rather than be placed (a missed crossing strands a track). - // See docs/mux-timeline.md#a_restarting_clock_falls_back_to_inference. + // A clip table that is not one advancing clock must fall back to inference rather than be + // placed (a missed crossing strands a track). #[test] fn a_restarting_clock_falls_back_to_inference() { let mk = |marks: &[(u32, u32)]| -> Vec<crate::disc::Clip> { @@ -1726,8 +1686,7 @@ mod tests { ); } - // Clips whose marks chain contiguously must come out byte-identical to - // the old behaviour. See docs/mux-timeline.md#contiguous_clips_produce_a_constant_offset. + // Clips whose marks chain contiguously must come out byte-identical to the old behaviour. #[test] fn contiguous_clips_produce_a_constant_offset() { // 0..2948.6667s, 2948.6667..6410.8667s, chained exactly. @@ -1774,8 +1733,7 @@ mod tests { ); } - // A SINGLE-clip title still gets a plan: trimming to `[in, out]` matters - // too. See docs/mux-timeline.md#single_clip_trims_content_outside_its_marks. + // A SINGLE-clip title still gets a plan: trimming to `[in, out]` matters too. #[test] fn single_clip_trims_content_outside_its_marks() { let clips = seamless_branching_clips(); // clip 0: in 4199.0s, out 6033.04s @@ -1824,9 +1782,8 @@ mod tests { assert_eq!(tc.offset_ns, 0); } - // PRIMARY rc3 regression: a sparse, lagging NON-VIDEO track on a - // SINGLE-clip title must NOT inflate `offset_ns`. See - // docs/mux-timeline.md#single_clip_late_subtitle_does_not_inflate_offset. + // PRIMARY rc3 regression: a sparse, lagging NON-VIDEO track on a SINGLE-clip title must NOT + // inflate `offset_ns`. #[test] fn single_clip_late_subtitle_does_not_inflate_offset() { let mut tc = TimelineContinuity::new(); @@ -1860,9 +1817,8 @@ mod tests { assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}"); } - // PRIMARY rc3 regression (Dolby Vision dual-layer): the EL must be a - // PASSIVE rider or every EL GOP false-triggers a clip-boundary reset. See - // docs/mux-timeline.md#dv_enhancement_layer_does_not_drive_epochs. + // PRIMARY rc3 regression (Dolby Vision dual-layer): the EL must be a PASSIVE rider or every + // EL GOP false-triggers a clip-boundary reset. #[test] fn dv_enhancement_layer_does_not_drive_epochs() { let mut tc = TimelineContinuity::new(); @@ -1886,8 +1842,7 @@ mod tests { assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}"); } - // Companion: a non-video frame must never ADVANCE the frontier, even one - // far ABOVE it. See docs/mux-timeline.md#non_video_never_advances_frontier. + // Companion: a non-video frame must never ADVANCE the frontier, even one far ABOVE it. #[test] fn non_video_never_advances_frontier() { let mut tc = TimelineContinuity::new(); @@ -1912,9 +1867,8 @@ mod tests { ); } - // Regression for the originally-reported band: a LARGE, real-magnitude - // clip-boundary back-jump on VIDEO must still rebase to one continuous - // timeline. See docs/mux-timeline.md#continuity_large_clip_boundary_backjump_rebased. + // Regression for the originally-reported band: a LARGE, real-magnitude clip-boundary + // back-jump on VIDEO must still rebase to one continuous timeline. #[test] fn continuity_large_clip_boundary_backjump_rebased() { let mut tc = TimelineContinuity::new(); @@ -1943,9 +1897,8 @@ mod tests { ); } - // At a REAL video-driven boundary, a lagging NON-VIDEO tail frame must be - // REMAPPED to its true seam position with the PREVIOUS offset. See - // docs/mux-timeline.md#non_video_straggler_remapped_to_seam_at_boundary. + // At a REAL video-driven boundary, a lagging NON-VIDEO tail frame must be REMAPPED to its + // true seam position with the PREVIOUS offset. #[test] fn non_video_straggler_remapped_to_seam_at_boundary() { let mut tc = TimelineContinuity::new(); @@ -1983,9 +1936,8 @@ mod tests { assert_eq!(normal, S + 600 * S + DISCONTINUITY_GAP_NS); } - // Regression for the over-eager straggler clamp: a NORMAL new-epoch - // non-video frame leading the frontier must ride the CURRENT offset, not - // be demoted. See docs/mux-timeline.md#normal_new_epoch_frame_leading_frontier_is_not_clamped. + // Regression for the over-eager straggler clamp: a NORMAL new-epoch non-video frame leading + // the frontier must ride the CURRENT offset, not be demoted. #[test] fn normal_new_epoch_frame_leading_frontier_is_not_clamped() { let mut tc = TimelineContinuity::new(); @@ -2016,9 +1968,8 @@ mod tests { ); } - // MEASURED on a real DVD title: audio arriving before its cell's video - // must continue after the frontier, then rejoin the real offset. See - // docs/mux-timeline.md#frames_arriving_before_their_epochs_video_ride_a_provisional_offset. + // MEASURED on a real DVD title: audio arriving before its cell's video must continue after + // the frontier, then rejoin the real offset. #[test] fn frames_arriving_before_their_epochs_video_ride_a_provisional_offset() { let mut tc = TimelineContinuity::new(); @@ -2131,9 +2082,8 @@ mod tests { ); } - // MEASURED on a real HD-DVD title: a straggler must be judged against - // its OWN epoch's end, not the current frontier. See - // docs/mux-timeline.md#a_straggler_is_judged_against_its_own_epochs_end_not_the_frontier. + // MEASURED on a real HD-DVD title: a straggler must be judged against its OWN epoch's end, + // not the current frontier. #[test] fn a_straggler_is_judged_against_its_own_epochs_end_not_the_frontier() { let mut tc = TimelineContinuity::new(); @@ -2186,9 +2136,8 @@ mod tests { ); } - // A saturated frontier (`high_ns` at `i64::MAX`, from a hostile mkv:// - // timestamp) must not panic the muxer. See - // docs/mux-timeline.md#a_saturated_frontier_does_not_overflow_on_passive_frame. + // A saturated frontier (`high_ns` at `i64::MAX`, from a hostile mkv:// timestamp) must not + // panic the muxer. #[test] fn saturated_frontier_does_not_overflow_on_passive_frame() { let mut tc = TimelineContinuity::new(); @@ -2203,9 +2152,8 @@ mod tests { assert_eq!(adj_other(&mut tc, i64::MAX), i64::MAX); } - // The epoch-decision side of the same arithmetic: both untrusted ends - // (i64::MIN/MAX) are reachable from container data. See - // docs/mux-timeline.md#extreme_video_pts_does_not_overflow_the_epoch_bump. + // The epoch-decision side of the same arithmetic: both untrusted ends (i64::MIN/MAX) are + // reachable from container data. #[test] fn extreme_video_pts_does_not_overflow_the_epoch_bump() { let mut tc = TimelineContinuity::new(); diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 0837dd8b..83fea5ac 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -94,9 +94,8 @@ const PES_BUFFER_INIT_CAP: usize = 16 * 1024; // PUSI; push() drops the partial PES and resyncs on the next PUSI past this cap. const MAX_PES_BUFFER: usize = 64 * 1024 * 1024; // 64 MiB -// Aggregate ceiling across every tracked PID; each PID's share (`pes_cap`) is -// derived from this so a crafted MPLS declaring many streams can't multiply -// MAX_PES_BUFFER by the stream count. See docs/ts-demux.md — MAX_PES_BUFFER_TOTAL. +// Aggregate ceiling across every tracked PID; each PID's share (`pes_cap`) is derived from this +// so a crafted MPLS declaring many streams can't multiply MAX_PES_BUFFER by the stream count. const MAX_PES_BUFFER_TOTAL: usize = 512 * 1024 * 1024; // 512 MiB impl PesAssembler { @@ -565,9 +564,9 @@ fn parse_timestamp(data: &[u8]) -> Option<i64> { Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1) } -// Canonical continuity-counter gap test (ISO/IEC 13818-1 S2.4.3.3), shared by -// the PES assembler and the PSI section reassembler so they can't drift; a -// legal duplicate CC repeat is not a gap. See docs/ts-demux.md — cc_is_gap. +// Canonical continuity-counter gap test (ISO/IEC 13818-1 S2.4.3.3), shared by the PES assembler +// and the PSI section reassembler so they can't drift; a legal duplicate CC repeat is not a +// gap. fn cc_is_gap(last_cc: Option<u8>, cc: u8) -> bool { match last_cc { Some(prev) => cc != ((prev + 1) & 0x0f) && cc != prev, @@ -612,9 +611,8 @@ fn psi_payload_base(pkt: &[u8]) -> Option<usize> { } } -// Reassemble a single PSI section (PAT/PMT) for `target_pid`/`table_id` across -// TS-packet boundaries. Returns the section bytes (from table_id) or None. -// See docs/ts-demux.md — collect_psi_section. +// Reassemble a single PSI section (PAT/PMT) for `target_pid`/`table_id` across TS-packet +// boundaries. Returns the section bytes (from table_id) or None. fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec<u8>> { let mut offset = 0; while offset + BD_SOURCE_PACKET_BYTES <= data.len() { @@ -2304,9 +2302,8 @@ mod tests { let _ = scan_streams(&data); } - // PES reassembly buffer cap (DoS hardening): must be a SHARE of the - // aggregate ceiling, not a flat 64 MiB/PID (see docs/ts-demux.md — - // MAX_PES_BUFFER_TOTAL). With 64 tracked PIDs, each share is 8 MiB. + // PES reassembly buffer cap (DoS hardening): must be a SHARE of the aggregate ceiling, not + // a flat 64 MiB/PID. With 64 tracked PIDs, each share is 8 MiB. #[test] fn per_pid_pes_cap_is_a_share_of_an_aggregate_ceiling() { let pids: Vec<u16> = (0x1000..0x1040).collect(); // 64 PIDs diff --git a/src/mux/tsmux.rs b/src/mux/tsmux.rs index 460cbad4..c01c3455 100644 --- a/src/mux/tsmux.rs +++ b/src/mux/tsmux.rs @@ -29,24 +29,19 @@ fn is_video_pid(pid: u16) -> bool { /// BD-TS muxer: PES frames in, 192-byte BD-TS packets out. /// -/// Constructed over an output writer and a slice of per-track PIDs. The -/// `track` index passed to [`TsMuxer::write_frame`] and -/// [`TsMuxer::set_codec_private`] is the position in that PID slice; all -/// per-track state vectors are sized to `pids.len()`. See docs/tsmux.md -/// for the video-vs-audio PID split and PTS-origin rules. +/// Constructed over an output writer and a slice of per-track PIDs. The `track` index passed to +/// [`TsMuxer::write_frame`] and [`TsMuxer::set_codec_private`] is the position in that PID +/// slice; all per-track state vectors are sized to `pids.len()`. pub struct TsMuxer<W: Write> { writer: W, pids: Vec<u16>, continuity: Vec<u8>, // per-PID continuity counter (0-15) codec_privates: Vec<Option<Vec<u8>>>, // per-track codec_private (for video parameter sets) params_written: Vec<bool>, // per-track: have we written parameter sets? - /// Per-track video codec: decides how the ES is framed AND how its - /// `codec_private` parameter-set record is parsed. HEVC/H.264 need - /// Annex-B conversion (hvcC/avcC parameter sets); MPEG-2/VC-1 are - /// already start-code ES and must NOT be converted. Defaults to - /// [`Codec::Hevc`]; ignored for non-video tracks. See docs/tsmux.md - /// for why NAL-ness is derived from this rather than tracked - /// separately. + /// Per-track video codec: decides how the ES is framed AND how its `codec_private` + /// parameter-set record is parsed. HEVC/H.264 need Annex-B conversion (hvcC/avcC parameter + /// sets); MPEG-2/VC-1 are already start-code ES and must NOT be converted. Defaults to + /// [`Codec::Hevc`]; ignored for non-video tracks. video_codec: Vec<Codec>, /// Global PTS origin (nanoseconds), seeded by the FIRST frame of any kind /// (video or audio) so a single fixed origin rebases every frame and the @@ -384,9 +379,8 @@ impl<W: Write> TsMuxer<W> { } } -// Build a PES packet header for a BD stream. `pts_90k` is `None` for a -// CONTINUATION PES (rest of an oversized private_stream_1 access unit) — -// only the first PES may carry a PTS. See docs/tsmux.md for the spec cite. +// Build a PES packet header for a BD stream. `pts_90k` is `None` for a CONTINUATION PES (rest +// of an oversized private_stream_1 access unit) — only the first PES may carry a PTS. fn build_pes_header(pid: u16, pts_90k: Option<u64>, data_len: usize) -> Vec<u8> { use crate::consts::pes_stream_id; // Determine stream_id from PID range @@ -853,9 +847,8 @@ mod tests { .collect() } - // Non-NAL codec (MPEG-2/VC-1) must pass ES through byte-for-byte; the - // payload is length-prefix SHAPED so a wrongly-applied Annex-B - // conversion is visibly detectable. See docs/tsmux.md for full rationale. + // Non-NAL codec (MPEG-2/VC-1) must pass ES through byte-for-byte; the payload is + // length-prefix SHAPED so a wrongly-applied Annex-B conversion is visibly detectable. #[test] fn non_nal_video_es_passes_through_unconverted() { // 4-byte BE length (6) + 6 payload bytes: exactly what the Annex-B @@ -1154,9 +1147,9 @@ mod tests { assert_eq!(got, big, "split audio reassembles byte-for-byte"); } - // ISO/IEC 13818-1 §2.4.3.7: only the first PES of a split access unit may - // carry a PTS; repeating it on continuations makes each look like an - // independent AU at the same timestamp. See docs/tsmux.md for detail. + // ISO/IEC 13818-1 §2.4.3.7: only the first PES of a split access unit may carry a PTS; + // repeating it on continuations makes each look like an independent AU at the same + // timestamp. #[test] fn split_access_unit_carries_pts_only_on_the_first_pes() { // Three PES worth of ES so there are two continuations to check. @@ -1185,9 +1178,8 @@ mod tests { ); } - // MEASURED: the Annex-B conversion buffer must be REUSED across video - // frames, not allocated per frame (~310 KB/frame otherwise). See - // docs/tsmux.md for the old-allocator regression this pins. + // MEASURED: the Annex-B conversion buffer must be REUSED across video frames, not allocated + // per frame (~310 KB/frame otherwise). #[test] fn annex_b_conversion_buffer_is_reused_across_frames() { let mut sink: Vec<u8> = Vec::new(); diff --git a/src/mux/videomap.rs b/src/mux/videomap.rs index 25ad2b07..3f86680b 100644 --- a/src/mux/videomap.rs +++ b/src/mux/videomap.rs @@ -5,42 +5,37 @@ //! ordered list of per-picture records: coding truth //! ([`PictureInfo`](crate::mux::codec::PictureInfo)) plus source provenance ([`SourcePos`](crate::pes::SourcePos)). //! -//! `VideoMap` is PURE DATA — it knows no output format; the `fvi://` sink does -//! the serialization to the on-disk FVI format (`docs/FVI_FORMAT.md`). -//! See docs/videomap-mod.md for the standalone-primitive / multi-sink design. +//! `VideoMap` is PURE DATA — it knows no output format; the `fvi://` sink does the +//! serialization to the on-disk FVI format. use crate::disc::{ColorSpace, DiscTitle, Stream as DiscStream, VideoStream}; use crate::mux::codec::PictureInfo; use crate::mux::codec::coding::{CodingType, FieldOrder}; use crate::pes::{PesFrame, SourcePos}; -// ── Format constants (cite docs/FVI_FORMAT.md) ─────────────────────────────── +// ── Format constants ───────────────────────────────. -/// Value of the header `"format"` member — the FVI document signature -/// (`docs/FVI_FORMAT.md` §6). Identifies a stream as a freemkv video index. +/// Value of the header `"format"` member — the FVI document signature. Identifies a stream as a +/// freemkv video index. pub const FVI_FORMAT: &str = "freemkv/video-index"; -/// Value of the header `"fvi_version"` member — the FVI document format version -/// (`docs/FVI_FORMAT.md` §6, §11). This spec defines `1`. +/// Value of the header `"fvi_version"` member — the FVI document format version. This spec +/// defines `1`. pub const FVI_VERSION: u32 = 1; -/// Producing tool tag for the header `"generator"` member -/// (`docs/FVI_FORMAT.md` §6). +/// Producing tool tag for the header `"generator"` member. pub const FVI_GENERATOR: &str = concat!("freemkv/", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX")); -/// Header `"timescale"` for all `pts`/`dts` ticks (`docs/FVI_FORMAT.md` §10). -/// The highway carries presentation timestamps in nanoseconds, so the timescale -/// is `1_000_000_000` ticks per second. +/// Header `"timescale"` for all `pts`/`dts` ticks. The highway carries presentation timestamps +/// in nanoseconds, so the timescale is `1_000_000_000` ticks per second. pub const FVI_TIMESCALE: u64 = 1_000_000_000; -/// Bytes per `src.sector` unit (`docs/FVI_FORMAT.md` §6.2, §9). The highway's -/// [`SourcePos`] counts 2048-byte logical sectors. +/// Bytes per `src.sector` unit. The highway's [`SourcePos`] counts 2048-byte logical sectors. pub const FVI_SECTOR_SIZE: u32 = crate::consts::SECTOR_BYTES as u32; // ── Logical model (serialization-independent) ──────────────────────────────── -/// Source-stream colour description (CICP code points), header-level -/// (`docs/FVI_FORMAT.md` §6.1 `colour`). +/// Source-stream colour description (CICP code points), header-level. /// /// Each field is the ITU-T H.273 / ISO 23091-2 code point for the title's /// primary video, derived from the disc's [`ColorSpace`]. `full_range` is the @@ -95,9 +90,8 @@ impl Colour { } } -/// Scan type for the header `stream.scan` member (`docs/FVI_FORMAT.md` §6.1). -/// `"mbaff"` is reachable only for codecs that signal it; MPEG-2 / disc video -/// resolves to `progressive` / `interlaced`. +/// Scan type for the header `stream.scan` member. `"mbaff"` is reachable only for codecs that +/// signal it; MPEG-2 / disc video resolves to `progressive` / `interlaced`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Scan { Progressive, @@ -113,11 +107,10 @@ impl Scan { } } -/// Source `medium` for the header `source.medium` member -/// (`docs/FVI_FORMAT.md` §6.2). Describes the physical/logical input the index -/// was built from — never the destination the index is written to. The driver -/// derives it from the `MuxInput` arm; [`Medium::File`] is the default only for -/// a caller that declares no provenance at all. +/// Source `medium` for the header `source.medium` member. Describes the physical/logical input +/// the index was built from — never the destination the index is written to. The driver derives +/// it from the `MuxInput` arm; [`Medium::File`] is the default only for a caller that declares +/// no provenance at all. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Medium { Disc, @@ -138,7 +131,7 @@ impl Medium { } } -/// Provenance root for the header (`docs/FVI_FORMAT.md` §6.2 `source`). +/// Provenance root for the header. #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct SourceInfo { /// Input medium. @@ -153,8 +146,7 @@ pub struct SourceInfo { pub volume_id: String, } -/// Per-title video facts for the header `stream` object -/// (`docs/FVI_FORMAT.md` §6.1). +/// Per-title video facts for the header `stream` object. #[derive(Clone, Debug, PartialEq, Eq)] pub struct StreamInfo { /// Registered codec id (Appendix B), e.g. `"mpeg2video"`, `"hevc"`. @@ -172,7 +164,7 @@ pub struct StreamInfo { pub colour: Colour, } -/// The header row: per-title facts (`docs/FVI_FORMAT.md` §6). +/// The header row: per-title facts. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MapHeader { /// The indexed elementary stream. @@ -183,9 +175,8 @@ pub struct MapHeader { pub picture_count: Option<u64>, } -/// Map the disc's `Codec` to a registered FVI codec id -/// (`docs/FVI_FORMAT.md` Appendix B). The disc-info `Codec::id` strings differ -/// (`"mpeg2"`/`"mpeg1"`); FVI uses the bitstream names. +/// Map the disc's `Codec` to a registered FVI codec id. The disc-info `Codec::id` strings +/// differ (`"mpeg2"`/`"mpeg1"`); FVI uses the bitstream names. fn fvi_codec_id(codec: crate::disc::Codec) -> &'static str { use crate::disc::Codec; match codec { @@ -259,8 +250,7 @@ fn display_aspect_ratio(v: &VideoStream, w: u32, h: u32) -> (u32, u32) { } } -/// One per-picture index record, distilled from a video [`PesFrame`] -/// (`docs/FVI_FORMAT.md` §7). +/// One per-picture index record, distilled from a video [`PesFrame`]. /// /// `coding` is the codec-agnostic per-picture truth ([`PictureInfo`], set by /// EVERY video parser that decodes coding — MPEG-2 fully, H.264/HEVC/VC-1 as @@ -285,7 +275,7 @@ pub struct PictureRecord { pub source: Option<SourcePos>, } -/// Record `type` label (`docs/FVI_FORMAT.md` §7), codec-agnostic. +/// Record `type` label, codec-agnostic. /// /// When `coding` is present (any video codec — every parser now fills it), the /// agnostic coding type is reported from [`PictureInfo::coding_type`]: @@ -310,10 +300,9 @@ pub fn type_label(coding: Option<PictureInfo>, keyframe: bool) -> &'static str { } } -/// Field-display-order label for the optional `field_order` member -/// (`docs/FVI_FORMAT.md` §7.1, Matroska element 0x9D), or `None` when the codec -/// did not measure it (signal absent / coding-type-only codec). `None` is an -/// HONEST absence — the writer OMITS the member rather than guessing a default. +/// Field-display-order label for the optional `field_order` member, or `None` when the codec +/// did not measure it (signal absent / coding-type-only codec). `None` is an HONEST absence — +/// the writer OMITS the member rather than guessing a default. pub fn field_order_label(coding: Option<PictureInfo>) -> Option<&'static str> { match coding?.field_order()? { FieldOrder::Tff => Some("tff"), @@ -322,14 +311,12 @@ pub fn field_order_label(coding: Option<PictureInfo>) -> Option<&'static str> { } } -/// Whether a picture is a random-access point for the `key` member -/// (`docs/FVI_FORMAT.md` §7), codec-agnostic. +/// Whether a picture is a random-access point for the `key` member, codec-agnostic. /// -/// For EVERY codec the frame's own `keyframe` flag IS the random-access signal: -/// IDR/IRAP for HEVC/H.264, the I-picture flag for MPEG-2/VC-1 — authored by -/// each codec's parser through the highway. `key` is the parser-flagged -/// decode-restart point (an intra picture), not the stricter open-GOP -/// clean-RAP precision; see `docs/FVI_FORMAT.md` for the honest limitation. +/// For EVERY codec the frame's own `keyframe` flag IS the random-access signal: IDR/IRAP for +/// HEVC/H.264, the I-picture flag for MPEG-2/VC-1 — authored by each codec's parser through the +/// highway. `key` is the parser-flagged decode-restart point (an intra picture), not the +/// stricter open-GOP clean-RAP precision. pub fn is_random_access(coding: Option<PictureInfo>, keyframe: bool) -> bool { // `PictureInfo` carries no GOP-closure (`closed_gop`/`gop_start`), so we // don't claim clean-RAP precision here; frame-flag alone is sufficient @@ -478,9 +465,8 @@ mod tests { assert_eq!(Colour::from_color_space(ColorSpace::Unknown).primaries, 2); } - // Regression: FVI colour must mirror the MKV muxer's precedence, not - // blindly map `color_space` → SDR transfer 14 for BT.2020. - // See docs/videomap-mod.md for the HDR10/measured-CICP bug history. + // Regression: FVI colour must mirror the MKV muxer's precedence, not blindly map + // `color_space` → SDR transfer 14 for BT.2020. #[test] fn fvi_colour_follows_hdr_and_measured_cicp() { use crate::disc::MeasuredCicp; diff --git a/src/platform/fs_type/linux.rs b/src/platform/fs_type/linux.rs index 087e16de..6f08da34 100644 --- a/src/platform/fs_type/linux.rs +++ b/src/platform/fs_type/linux.rs @@ -19,9 +19,8 @@ const XFS_SUPER_MAGIC: i64 = 0x5846_5342; const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E; const TMPFS_MAGIC: i64 = 0x0102_1994; -// Classify an `f_type` magic from `statfs`/`fstatfs`; shared by the -// path-based and fd-based entry points. See docs/fs-type-linux.md for -// why the `i64` cast (and its clippy allow) is needed. +// Classify an `f_type` magic from `statfs`/`fstatfs`; shared by the path-based and fd-based +// entry points. #[allow(clippy::unnecessary_cast)] fn classify_f_type(f_type: i64) -> FsType { let nfs_magic = libc::NFS_SUPER_MAGIC as i64; diff --git a/src/platform/fs_type/mod.rs b/src/platform/fs_type/mod.rs index 1c588075..fcdff8df 100644 --- a/src/platform/fs_type/mod.rs +++ b/src/platform/fs_type/mod.rs @@ -1,10 +1,8 @@ //! Filesystem-type detection. //! -//! Picks the output sink for the Phase 2 buffering architecture (NFS -//! gets `WritebackFile`, local disks get `LocalFileSink`) and exposes -//! the cross-platform enum and `detect` entry point; the per-OS -//! `statfs` call lives in the matching platform file. See -//! docs/fs-type.md for the full rationale. +//! Picks the output sink for the Phase 2 buffering architecture (NFS gets `WritebackFile`, +//! local disks get `LocalFileSink`) and exposes the cross-platform enum and `detect` entry +//! point; the per-OS `statfs` call lives in the matching platform file. use std::path::Path; diff --git a/src/progress.rs b/src/progress.rs index 5ee7223c..8fada24c 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -5,8 +5,6 @@ //! same [`PassProgress`] shape via the [`Progress`] trait. Consumers (autorip) //! compute their own single derived view from these fields and never reach //! into per-pass internals. -//! -//! See docs/progress.md — why this design (pre-0.13.16 leaked-field history). /// Identifies which pipeline phase the progress event belongs to. /// @@ -82,9 +80,6 @@ pub struct LocatedProgress { /// regardless of which kind of pass is running. `bytes_good_total` is the /// cumulative count of confirmed-clean bytes across the whole rip; useful /// for the "data recovered" stat the user sees. -/// -/// See docs/progress.md — `PassKind::Verify` field mapping, and why -/// `PassProgress` is NOT `Copy`. #[derive(Debug, Clone)] pub struct PassProgress { pub kind: PassKind, @@ -166,8 +161,6 @@ impl PassProgress { /// emits a `DEBUG` event on target `freemkv::heartbeat` at most once per /// interval (default 5s), so a stalled loop is visible in the log as the /// absence of a beat. -/// -/// See docs/progress.md — hot-path cost and [`tick_cpu`](Heartbeat::tick_cpu). #[derive(Debug)] pub struct Heartbeat { phase: &'static str, diff --git a/src/scsi/fd_handoff.rs b/src/scsi/fd_handoff.rs index 90b4035b..7a898dc9 100644 --- a/src/scsi/fd_handoff.rs +++ b/src/scsi/fd_handoff.rs @@ -1,117 +1,17 @@ -//! The fd hand-off between a Linux recovery thread and the transport that -//! spawned it. +//! Owned-fd handoff between Linux transport recovery and teardown. //! -//! When `SgIoTransport::execute` sees a transport-level failure it abandons -//! its fd and spawns a detached thread to open a fresh one. That thread has no -//! reference back to the transport — the transport may have been dropped -//! before the `open()` returns — so the two sides rendezvous through a -//! single-slot mailbox: an `AtomicI32` holding an owned fd (`-1` = empty) and -//! an `AtomicBool` saying whether the transport is already gone. -//! -//! The invariant, and the only thing that matters here: **every fd that -//! reaches the slot is closed exactly once.** Closing zero times leaks a -//! descriptor; closing twice is worse than a leak, because a `close()` of an -//! fd number the process has since reused closes an unrelated file. -//! -//! # Why this lives in `scsi/`, not in `linux.rs` -//! -//! `linux.rs` is compiled on exactly one host, so nothing in it can be tested -//! on macOS or Windows CI — the same reason [`super::checked_cdb_len`] lives -//! in the parent module. The protocol below is plain atomics with no syscalls -//! in it, so it is compiled and tested on every platform even though Linux is -//! its only caller. -//! -//! # Memory ordering -//! -//! The subtle case is a `Drop` that races the recovery thread. The two sides -//! are ordered only through the slot, so the release edge has to be carried by -//! the slot's own operations: -//! -//! 1. [`claim_for_teardown`] publishes `dead = true` and then claims the slot. -//! 2. [`publish_recovered_fd`] fills the slot and then reads `dead`. -//! -//! Publish-first needs nothing special: teardown's swap is an RMW, so it reads -//! the last value in the slot's modification order — the fd — and closes it by -//! coherence alone. The dangerous order is teardown-first, where -//! `claim_for_teardown` finds the slot empty and returns, leaving the recovery -//! thread as the only one that can close the fd — and it will only do so if its -//! `dead` load observes the `true`. -//! -//! That observation is not free. Rust's atomics follow the C++20 rules -//! (`std::sync::atomic` module docs: "Rust atomics currently follow the same -//! rules as C++20 atomics, specifically the rules from the `intro.races` -//! section, without the 'consume' memory ordering"), and under those rules: -//! -//! - Every operation on the slot is a read-modify-write — `swap` or -//! `compare_exchange`, never a plain store after construction. That matters, -//! because "a release sequence headed by a release operation A on an atomic -//! object M is a maximal contiguous sub-sequence of side effects in the -//! modification order of M, where the first operation is A, and every -//! subsequent operation is an atomic read-modify-write operation" -//! ([intro.races], N4868). So once `claim_for_teardown`'s `swap` executes, -//! *every* later slot operation is in its release sequence — including one -//! that lands behind an intervening drain or a second recovery thread, -//! which is why the argument does not depend on the CAS reading the `swap`'s -//! value directly. -//! - Landing in that sequence is still not enough to synchronize with its -//! head. That needs both halves: "an atomic operation A that performs a -//! release operation on an atomic object M synchronizes with an atomic -//! operation B that performs an acquire operation on M and takes its value -//! from any side effect in the release sequence headed by A" -//! ([atomics.order]/2, N4868). -//! -//! Both halves of that were missing: -//! -//! - `swap(-1, Acquire)` is not a release store. Per the [`Ordering::Acquire`] -//! docs, "using this ordering for an operation that combines loads and -//! stores leads to a `Relaxed` store operation" — so it heads no release -//! sequence, and there was nothing for the CAS to synchronize with. -//! - `compare_exchange(.., Release, Relaxed)` is not an acquire load. Per the -//! [`Ordering::Release`] docs, it "leads to a `Relaxed` load operation" — so -//! even against a proper release store it would establish no edge. -//! -//! Upgrading only one side therefore fixes nothing; the loom models in this -//! file still report the leak with either half applied alone. Both are -//! `AcqRel` below. -//! -//! Concretely, the CAS in [`publish_recovered_fd`] carries both roles at once: -//! its release half publishes the `open()` to [`take_recovered_fd`], and its -//! acquire half is what lets the `dead` load that follows observe a teardown -//! that has already claimed the slot. -//! -//! [`take_recovered_fd`] is deliberately left at `Acquire`, which makes its -//! store half relaxed. That is harmless precisely because of the release- -//! sequence rule above: an RMW extends the sequence whatever its own ordering, -//! so a drain landing between `claim_for_teardown` and a later CAS does not -//! break the edge. It needs no release half of its own — it is called from -//! `execute()`, which cannot run concurrently with `Drop` (both need the -//! transport), so it never has anything of its own to publish to a teardown. -//! `loom_drain_does_not_break_the_release_sequence` is the check on that. -//! -//! `dead`'s `Release` store is belt-and-braces: it is sequenced before the -//! `AcqRel` swap, and the swap is what carries the edge. Downgrading it to -//! `Relaxed` leaves all three models passing. -//! -//! # Building the model +//! Every fd published to the atomic slot must be claimed and closed exactly once. +//! Teardown stores `dead = true` before its AcqRel swap; publication uses an +//! AcqRel CAS before checking `dead`. Both halves are required for the publisher +//! to observe teardown when it claims an empty slot after teardown has returned. +//! All slot updates are RMWs, so intervening drains preserve the release sequence. +//! The normal drain needs only Acquire because it cannot race its own transport's Drop. //! +//! The protocol is platform-independent and tested with Loom's atomics: //! ```text -//! RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features \ -//! --features scsi fd_handoff +//! RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features --features scsi fd_handoff //! ``` -//! -//! Under those flags the protocol runs on loom's instrumented atomics, so the -//! models check the code the transport actually calls rather than a -//! transcription that could drift from it. The alias below is gated on `test` -//! as well as `loom`, because `loom` is a DEV-dependency and so is not in the -//! graph of a non-test target: `cfg(loom)` alone would break `cargo build`, -//! `cargo check` and `cargo clippy --all-targets` under those flags with an -//! unresolved-crate error. Non-test targets keep std's atomics. -//! -//! One consequence to know about: under `all(loom, test)`, `SgIoTransport`'s -//! atomics are loom's, and those panic outside a `loom::model`. No unit test -//! constructs a transport today — `raw_command` is a free-standing `fn` that -//! touches none — but the first one that does must live behind -//! `cfg(not(loom))`. +//! Under `all(loom, test)`, callers must execute inside `loom::model`. // See "Building the model" above for why this is gated on `test` too. #[cfg(all(loom, test))] diff --git a/src/scsi/linux.rs b/src/scsi/linux.rs index 5331d33d..1bf09fed 100644 --- a/src/scsi/linux.rs +++ b/src/scsi/linux.rs @@ -5,9 +5,6 @@ //! No userspace abort, no fd close+reopen, no SG_SCSI_RESET escalation — //! the kernel SCSI mid-layer's own error-handling ladder runs internally //! when `hdr.timeout` expires. -//! -//! See docs/scsi-linux.md for the escalation ladder, the design -//! rationale, and the pre-0.13.20 async-poll design it replaced. use super::fd_handoff::{ AtomicBool, AtomicI32, claim_for_teardown, publish_recovered_fd, take_recovered_fd, @@ -128,11 +125,8 @@ impl SgIoTransport { /// live `&mut self` to route through `execute()`: `Drop`, unlocking the /// tray on its way out. /// - /// The CDB goes through the shared [`super::checked_cdb_len`] guard rather - /// than the old `cdb.len().min(16)` clamp — see docs/scsi-mod.md, - /// "checked_cdb_len rationale". Returning a typed [`Error`] rather than - /// `()` is what lets `raw_command_cdb_guard_tests` tell a rejected CDB - /// from an I/O failure without a real `/dev/sg*` device. + /// The CDB goes through the shared [`super::checked_cdb_len`] guard rather than the old + /// `cdb.len().min(16)` clamp. fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> Result<()> { let cmd_len = super::checked_cdb_len(cdb, K_MAX_CDB_SIZE)?; let mut sense = [0u8; 32]; @@ -230,8 +224,6 @@ impl Drop for SgIoTransport { impl ScsiTransport for SgIoTransport { // Execute via one synchronous SG_IO ioctl; errors map to IoError/ScsiError. - // See docs/scsi-linux.md — error mapping, DRIVER_SENSE masking, and - // partial-transfer notes. fn execute( &mut self, cdb: &[u8], diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index 8860ba96..07523121 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -1,13 +1,11 @@ //! macOS SCSI transport: IOKit SCSITaskDeviceInterface with exclusive access. //! -//! Single dispatch path: **all** CDBs go through -//! `SCSITaskDeviceInterface::ExecuteTaskSync`, 1:1 with the Linux SG_IO -//! backend. The C shim (`macos_shim.c`) unmounts, opens, and obtains -//! exclusive access before dispatch; see `docs/scsi-macos.md` for the -//! full open sequence. +//! Single dispatch path: **all** CDBs go through `SCSITaskDeviceInterface::ExecuteTaskSync`, +//! 1:1 with the Linux SG_IO backend. The C shim (`macos_shim.c`) unmounts, opens, and obtains +//! exclusive access before dispatch. //! -//! Drive enumeration and the media-presence probe use the IOKit registry -//! directly and never take exclusive access; see `docs/scsi-macos.md`. +//! Drive enumeration and the media-presence probe use the IOKit registry directly and never +//! take exclusive access. use super::{DataDirection, ScsiResult, ScsiTransport}; use crate::error::{Error, Result}; @@ -21,9 +19,8 @@ const K_SENSE_DATA_SIZE: usize = 32; /// shim so a pathological >255-byte slice can't wrap a `u8`. const K_MAX_CDB_SIZE: usize = 16; -// The C shim uses a single global IOKit handle, so only one -// MacScsiTransport may exist at a time — a second open() would race the -// shared handle with the first drop(). See docs/scsi-macos.md. +// The C shim uses a single global IOKit handle, so only one MacScsiTransport may exist at a +// time — a second open() would race the shared handle with the first drop(). static OPEN: AtomicBool = AtomicBool::new(false); #[repr(C)] @@ -74,9 +71,8 @@ fn device_path_for_selector(selector: &str) -> String { } } -// Maps a shim_open_exclusive failure sentinel (negative rc, not an -// IOReturn) to its typed Error variant, pulled out standalone so the -// mapping can be unit-tested. See docs/scsi-macos.md. +// Maps a shim_open_exclusive failure sentinel (negative rc, not an IOReturn) to its typed Error +// variant, pulled out standalone so the mapping can be unit-tested. fn map_shim_open_error(rc: i32, path: String) -> Error { match rc { // -2/-3/-4: IOCreatePlugInInterfaceForService / @@ -257,9 +253,9 @@ fn cstr_to_str(bytes: &[u8]) -> &str { std::str::from_utf8(&bytes[..end]).unwrap_or("") } -// Media-presence probe via the IOKit registry only — no exclusive access, -// no unmount, no SCSI command (open() force-unmounts the disc; a probe -// documented as side-effect-free must never do that). See docs/scsi-macos.md. +// Media-presence probe via the IOKit registry only — no exclusive access, no unmount, no SCSI +// command (open() force-unmounts the disc; a probe documented as side-effect-free must never do +// that). pub(super) fn drive_has_disc(path: &Path) -> Result<bool> { let bsd_name = bsd_name_of(path)?; let mut bsd_c = bsd_name.as_bytes().to_vec(); @@ -381,9 +377,8 @@ mod tests { assert_eq!(cstr_to_str(&bytes), ""); } - // Regression test: drive_has_disc used to open a FULL exclusive - // transport (force-unmount + 500ms sleep) on every poll tick. Checks - // it now answers Ok(false) fast with no lock held. See docs/scsi-macos.md. + // Regression test: drive_has_disc used to open a FULL exclusive transport (force-unmount + + // 500ms sleep) on every poll tick. Checks it now answers Ok(false) fast with no lock held. #[test] fn presence_probe_does_not_open_a_transport() { let path = Path::new("/dev/freemkv-no-such-device"); diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index 95279dd3..df180c8f 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -50,14 +50,12 @@ pub const AACS_KEY_CLASS: u8 = 0x02; pub(crate) const TUR_TIMEOUT_MS: u32 = 5_000; // Timeout for content READ (READ_10/12) on the ripping fast path -// (`freemkv_engine::recovery::copy`). 10s, calibrated on an LG BU40N + -// Initio 1618L bridge. See docs/scsi-mod.md — READ_TIMEOUT_MS calibration. +// (`freemkv_engine::recovery::copy`). 10s, calibrated on an LG BU40N + Initio 1618L bridge. #[cfg(feature = "rip")] pub(crate) const READ_TIMEOUT_MS: u32 = 10_000; -// Timeout for content READ on the recovery path (`recovery::patch`'s -// targeted retries). Matches sg_dd's 60s ceiling; failed reads usually -// return in 1-4s. See docs/scsi-mod.md — READ_RECOVERY_TIMEOUT_MS history. +// Timeout for content READ on the recovery path (`recovery::patch`'s targeted retries). Matches +// sg_dd's 60s ceiling; failed reads usually return in 1-4s. #[cfg(feature = "rip")] pub(crate) const READ_RECOVERY_TIMEOUT_MS: u32 = 60_000; @@ -76,8 +74,7 @@ pub const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF; // ── CDB length validation ────────────────────────────────────────────────── -// Validate a CDB against a transport's field width; REJECT (never -// truncate) an over-length CDB — see docs/scsi-mod.md — checked_cdb_len rationale. +// Validate a CDB against a transport's field width; REJECT (never truncate) an over-length CDB. pub(crate) fn checked_cdb_len(cdb: &[u8], max: usize) -> Result<u8> { if cdb.is_empty() || cdb.len() > max { return Err(Error::InvalidCdbLength { @@ -273,13 +270,11 @@ impl ScsiSense { ascq: 0, }; - /// `true` when the sense key indicates a *marginal-read* failure — one - /// where a retry or smaller-granularity read sometimes succeeds: - /// MEDIUM ERROR, NOT READY (dominant on BU40N), ABORTED COMMAND, - /// RECOVERED ERROR, or NO SENSE. `false` for HARDWARE ERROR, DATA - /// PROTECT, UNIT ATTENTION, ILLEGAL REQUEST, BLANK CHECK, and unknown - /// keys. Used by [`Error::is_marginal_read`] / the recovery-copy - /// hysteresis dispatch. See docs/scsi-mod.md — is_marginal detail. + /// `true` when the sense key indicates a *marginal-read* failure — one where a retry or + /// smaller-granularity read sometimes succeeds: MEDIUM ERROR, NOT READY (dominant on + /// BU40N), ABORTED COMMAND, RECOVERED ERROR, or NO SENSE. `false` for HARDWARE ERROR, DATA + /// PROTECT, UNIT ATTENTION, ILLEGAL REQUEST, BLANK CHECK, and unknown keys. Used by + /// [`Error::is_marginal_read`] / the recovery-copy hysteresis dispatch. pub fn is_marginal(&self) -> bool { matches!( self.sense_key, @@ -347,9 +342,9 @@ impl ScsiSense { } } -// Decode an SPC-4 sense buffer into (sense_key, asc, ascq), handling both -// descriptor (0x72/0x73) and fixed (0x70/0x71) response-code formats; pure -// function shared by all three platform backends. See docs/scsi-mod.md — parse_sense format details. +// Decode an SPC-4 sense buffer into (sense_key, asc, ascq), handling both descriptor +// (0x72/0x73) and fixed (0x70/0x71) response-code formats; pure function shared by all three +// platform backends. pub(crate) fn parse_sense(sense: &[u8], sb_len_wr: u8) -> ScsiSense { let n = (sb_len_wr as usize).min(sense.len()); if n < 3 { @@ -418,9 +413,8 @@ pub trait ScsiTransport: Send { /// into chunks by the caller ([`crate::Drive::read`]) — otherwise the /// transport fails the whole command. /// - /// Default is a conservative 1 MiB, safe on every platform. Windows - /// overrides with the adapter's real `MaximumTransferLength`; see - /// docs/scsi-mod.md — max_transfer_bytes Windows rationale. + /// Default is a conservative 1 MiB, safe on every platform. Windows overrides with the + /// adapter's real `MaximumTransferLength` fn max_transfer_bytes(&self) -> usize { 1 << 20 } @@ -454,8 +448,7 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> { } } -// scsi::reset()/usb_reset() (removed 0.13.6/0.13.4) and hardware-touching -// API surface history: see docs/scsi-mod.md — scsi module reset history. +// scsi::reset()/usb_reset() (removed 0.13.6/0.13.4) and hardware-touching API surface history: /// One optical drive on the system. Returned by [`list_drives`]. The /// fields are populated from a single INQUIRY at enumeration time — @@ -512,9 +505,8 @@ pub fn list_drives() -> Vec<DriveInfo> { /// ready/not-ready response → `Ok(true)`. Suitable for poll-loop tick /// (~50 ms / drive on a healthy bus). /// -/// **No internal recovery.** A wedged target surfaces as -/// `Err(Error::ScsiError)` with `status == SCSI_STATUS_TRANSPORT_FAILURE` -/// and `sense: None` — no bus/USB reset, no retry. See docs/scsi-mod.md. +/// **No internal recovery.** A wedged target surfaces as `Err(Error::ScsiError)` with `status +/// == SCSI_STATUS_TRANSPORT_FAILURE` and `sense: None` — no bus/USB reset, no retry. pub fn drive_has_disc(path: &Path) -> Result<bool> { #[cfg(target_os = "linux")] { @@ -637,9 +629,8 @@ pub fn build_read10_fua(lba: u32, count: u16) -> [u8; 10] { ] } -// Round `p` up to satisfy an SPTI AlignmentMask (`(p + mask) & !mask`); -// mask=0 means no alignment requirement. See docs/scsi-mod.md — -// align_up mask semantics and Windows SPTI sharing rationale. +// Round `p` up to satisfy an SPTI AlignmentMask (`(p + mask) & !mask`); mask=0 means no +// alignment requirement. #[allow(dead_code)] pub(crate) fn align_up(p: usize, mask: usize) -> usize { (p + mask) & !mask diff --git a/src/scsi/windows.rs b/src/scsi/windows.rs index 5413be04..ac08291a 100644 --- a/src/scsi/windows.rs +++ b/src/scsi/windows.rs @@ -15,9 +15,8 @@ const IOCTL_SCSI_PASS_THROUGH_DIRECT: u32 = 0x4D014; /// IOCTL_STORAGE_QUERY_PROPERTY — CTL_CODE(IOCTL_STORAGE_BASE(0x2D), /// 0x500, METHOD_BUFFERED(0), FILE_ANY_ACCESS(0)) = 0x002D1400. const IOCTL_STORAGE_QUERY_PROPERTY: u32 = 0x002D1400; -// IOCTL_STORAGE_RESET_DEVICE (ntddstor.h) = 0x002D5004. Two earlier values -// silently no-op'd the reset (ERROR_INVALID_FUNCTION) instead of failing -// loud. See docs/scsi-windows.md — IOCTL_STORAGE_RESET_DEVICE derivation. +// IOCTL_STORAGE_RESET_DEVICE (ntddstor.h) = 0x002D5004. Two earlier values silently no-op'd the +// reset (ERROR_INVALID_FUNCTION) instead of failing loud. const IOCTL_STORAGE_RESET_DEVICE: u32 = 0x002D_5004; /// STORAGE_PROPERTY_ID::StorageAdapterProperty. const STORAGE_ADAPTER_PROPERTY: u32 = 1; @@ -83,9 +82,8 @@ struct StoragePropertyQuery { AdditionalParameters: [u8; 1], } -// Prefix of `STORAGE_ADAPTER_DESCRIPTOR` (winioctl.h) up to -// `MaximumTransferLength`; the driver fills the whole thing, we only read -// this prefix. See docs/scsi-windows.md — StorageAdapterDescriptor field layout. +// Prefix of `STORAGE_ADAPTER_DESCRIPTOR` (winioctl.h) up to `MaximumTransferLength`; the driver +// fills the whole thing, we only read this prefix. #[repr(C)] #[allow(non_snake_case)] struct StorageAdapterDescriptor { @@ -144,20 +142,18 @@ pub struct SptiTransport { /// [`WINDOWS_MIN_TRANSFER_BYTES`]. A single READ larger than this fails /// `DeviceIoControl` outright, so [`crate::Drive::read`] chunks to it. max_transfer: usize, - /// Adapter `AlignmentMask` (STORAGE_ADAPTER_DESCRIPTOR, ntddscsi.h / - /// winioctl.h), queried alongside `max_transfer`. `0` (common on USB - /// bridges) means no alignment requirement; a nonzero mask (SCSI/SAS - /// HBAs) means a misaligned `DataBuffer` makes `DeviceIoControl` fail. - /// When set and the caller's buffer is misaligned, `execute()` bounces - /// through an aligned scratch buffer (see there). - /// See docs/scsi-windows.md — alignment_mask / bounce-buffer rationale. + /// Adapter `AlignmentMask` (STORAGE_ADAPTER_DESCRIPTOR, ntddscsi.h / winioctl.h), queried + /// alongside `max_transfer`. `0` (common on USB bridges) means no alignment requirement; a + /// nonzero mask (SCSI/SAS HBAs) means a misaligned `DataBuffer` makes `DeviceIoControl` + /// fail. When set and the caller's buffer is misaligned, `execute()` bounces through an + /// aligned scratch buffer (see there). alignment_mask: u32, } // SptiTransport's only field is an isize HANDLE: Send+Sync auto-derive; // `&mut self` on execute() enforces exclusive use, not absence of Sync. -// Normalize a device path to \\.\X: format; see docs/scsi-windows.md — normalize_device_path duplication. +// Normalize a device path to \\.\X: format. fn normalize_device_path(path: &str) -> String { if path.starts_with("\\\\.\\") { return path.to_string(); @@ -355,9 +351,8 @@ impl Drop for SptiTransport { } } -// Query the adapter descriptor via IOCTL_STORAGE_QUERY_PROPERTY; returns -// (max_transfer_bytes, alignment_mask), both safely defaulted on failure. -// See docs/scsi-windows.md — query_adapter_descriptor contract. +// Query the adapter descriptor via IOCTL_STORAGE_QUERY_PROPERTY; returns (max_transfer_bytes, +// alignment_mask), both safely defaulted on failure. fn query_adapter_descriptor(handle: isize) -> (usize, u32) { if handle == INVALID_HANDLE_VALUE { return (WINDOWS_MIN_TRANSFER_BYTES, 0); @@ -563,9 +558,8 @@ impl ScsiTransport for SptiTransport { mod tests { use super::*; - // Regression guard for `SptiTransport::reset()`: recomputes - // IOCTL_STORAGE_RESET_DEVICE from the CTL_CODE formula, independently of - // the hardcoded constant. See docs/scsi-windows.md — IOCTL_STORAGE_RESET_DEVICE derivation. + // Regression guard for `SptiTransport::reset()`: recomputes IOCTL_STORAGE_RESET_DEVICE from + // the CTL_CODE formula, independently of the hardcoded constant. #[test] fn ioctl_storage_reset_device_value_is_correct() { // CTL_CODE(DeviceType, Function, Method, Access) = @@ -595,9 +589,8 @@ mod tests { assert_ne!(IOCTL_STORAGE_RESET_DEVICE, 0x002D_D000); } - // Regression guard: `StorageAdapterDescriptor` must match - // `STORAGE_ADAPTER_DESCRIPTOR` (winioctl.h) field-for-field. See - // docs/scsi-windows.md — StorageAdapterDescriptor field layout. + // Regression guard: `StorageAdapterDescriptor` must match `STORAGE_ADAPTER_DESCRIPTOR` + // (winioctl.h) field-for-field. #[test] fn storage_adapter_descriptor_matches_sdk_layout() { use std::mem::{offset_of, size_of}; @@ -626,9 +619,8 @@ mod tests { assert_eq!(size_of::<StorageAdapterDescriptor>(), 32); } - // Regression guard for `ScsiPassThroughDirect`, cross-checked against - // the SDK's `SCSI_PASS_THROUGH_DIRECT` (natural alignment, no pack). - // Do NOT add `packed(4)` — see docs/scsi-windows.md — ScsiPassThroughDirect layout, cross-checked against SDK. + // Regression guard for `ScsiPassThroughDirect`, cross-checked against the SDK's + // `SCSI_PASS_THROUGH_DIRECT` (natural alignment, no pack). Do NOT add `packed(4)` #[test] #[cfg(target_pointer_width = "64")] fn scsi_pass_through_direct_matches_sdk_layout_win64() { @@ -659,9 +651,8 @@ mod tests { assert_eq!(size_of::<StoragePropertyQuery>(), 12); } - // Cross-checked against the Windows SDK headers: each IOCTL is asserted - // against an independent re-derivation of `CTL_CODE`, not just its - // literal. See docs/scsi-windows.md — windows_ffi_constants_match_sdk test rationale. + // Cross-checked against the Windows SDK headers: each IOCTL is asserted against an + // independent re-derivation of `CTL_CODE`, not just its literal. #[test] fn windows_ffi_constants_match_sdk() { // CTL_CODE re-derivation. FILE_DEVICE_CONTROLLER=0x4, diff --git a/src/sector/bus_removal.rs b/src/sector/bus_removal.rs index 6db35818..55527087 100644 --- a/src/sector/bus_removal.rs +++ b/src/sector/bus_removal.rs @@ -1,42 +1,15 @@ -//! `BusRemovalSectorSource` — a stream form of AACS bus-encryption removal. +//! AACS bus-encryption removal for sector streams. //! -//! # Where bus removal actually happens -//! AACS 2.0 "bus encryption" (BEE) wraps content on the drive↔host transport, -//! and removing it is the UNLOCKER's job. The single de-bus TRANSFORM is -//! [`decrypt_bus_in_content`]; in the PRODUCTION path it is applied once, at the -//! very bottom of the read chain, inside the live drive's own `SectorSource` -//! impl (`Drive::remove_bus_encryption`, gated by the drive's `BusStage` + -//! content ranges). Every reader above it (key sampler, mux, unit-key -//! validation, whole-disc sweep) reads the `Drive` as `&mut dyn SectorSource` -//! and so transparently sees bus-removed sectors and never reasons about bus -//! encryption. +//! The live `Drive` already removes bus encryption. Do not wrap it in this +//! adapter: applying the transform twice corrupts content. This adapter is for +//! other sources and tests, using the same [`decrypt_bus_in_content`] transform. +//! [`BusStage::Passthrough`] leaves sectors untouched; [`BusStage::AacsHostKey`] +//! applies the host key obtained through the AACS handshake. //! -//! This decorator is an equivalent, independently-tested STREAM form of that -//! same transform (it also calls [`decrypt_bus_in_content`]), kept for tests and -//! for any future non-`Drive` source that needs de-bussing. It is NOT wired into -//! the production chain today. DANGER: never wrap the live `Drive` in this -//! decorator — the `Drive` already de-busses, so a second pass would DOUBLE- -//! de-bus and corrupt content. There must only ever be ONE de-bus in a chain. -//! -//! # How the stage is chosen -//! [`BusStage`] is computed ONCE from the unlock result: -//! * a firmware/vendor unlocker (freemkv / LibreDrive / Renesas) removes bus -//! encryption AT THE DRIVE (`Bus=off`), and a non-bus disc (DVD / clear BD) -//! has none → [`BusStage::Passthrough`] (reads pass through untouched); -//! * the AACS cert route yields the Read Data Key from the AKE, so the host -//! must de-bus each content sector → [`BusStage::AacsHostKey`]. -//! -//! # Content gating -//! De-bussing a clear UDF/nav sector would corrupt plaintext, so the host-key -//! path is gated to the disc's encrypted-content extents via -//! [`with_content_ranges`](BusRemovalSectorSource::with_content_ranges) — the -//! same `(start_lba, sector_count)` map [`DecryptingSectorSource`] uses. Absent -//! a map, the caller reads content-only (title extents) and every sector is -//! treated as content — matching the pre-overhaul behaviour where `decrypt_bus` -//! ran on content units only. +//! Content ranges protect clear UDF/navigation sectors from decryption. Without +//! ranges, the caller must supply content-only sectors. //! //! [`decrypt_bus_in_content`]: crate::aacs::content::decrypt_bus_in_content -//! [`DecryptingSectorSource`]: crate::sector::decrypting::DecryptingSectorSource use std::sync::Arc; diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 3185776a..e93dc383 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -7,8 +7,6 @@ //! //! Composition: `Drive` → `DecryptingSectorSource` → caller sees //! plaintext; `DecryptKeys::None` discs pass through unconditionally. -//! -//! See docs/decrypting-sector-source.md for the single-source-of-truth rationale. use crate::decrypt::{DecryptKeys, decrypt_sectors, decrypt_sectors_in_content}; use crate::error::Result; @@ -29,8 +27,6 @@ pub type KeyFetchFn = std::sync::Arc<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send /// anchor batch. Both return additional keys to add to the pool and retry /// with; empty if the source can't help. The library does no key lookup or /// network I/O itself — this is the caller's seam to its key source. -/// -/// See docs/decrypting-sector-source.md for the two-operation rationale. #[derive(Clone)] pub struct KeyFetch { unit: KeyFetchFn, diff --git a/src/sector/mod.rs b/src/sector/mod.rs index b2f54ecc..f636c8f3 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -64,8 +64,6 @@ pub trait SectorSource: Send { recovery: bool, fua: bool, ) -> Result<usize> { - // See docs/sector.md — Pass-N marginal-sector FUA rationale and the - // ~10x throughput cost of applying it to the bulk path. let _ = fua; self.read_sectors(lba, count, buf, recovery) } @@ -230,8 +228,8 @@ mod tests { } } - // Routes through a generic `S: SectorSource` bound so the forwarding - // impls (not the vtable) are exercised. See docs/sector.md. + // Routes through a generic `S: SectorSource` bound so the forwarding impls (not the vtable) + // are exercised. fn set_unit_base_generic<S: SectorSource>(mut s: S, base: u32) { s.set_unit_base(base); } @@ -390,8 +388,8 @@ mod tests { } } - // Routes through a generic `S: SectorSource` bound so the forwarding - // impl (not the vtable) runs. See docs/sector.md. + // Routes through a generic `S: SectorSource` bound so the forwarding impl (not the vtable) + // runs. fn read_generic<S: SectorSource>( mut s: S, lba: u32, @@ -422,8 +420,7 @@ mod tests { } // Proves the forwarding impl actually delegates `read_sectors`, unlike - // `mut_ref_dyn_forwards_all_methods` (vtable dispatch). See - // docs/sector.md. + // `mut_ref_dyn_forwards_all_methods` (vtable dispatch). #[test] fn mut_ref_dyn_forwards_read_sectors_to_the_inner_source() { let calls = Arc::new(Mutex::new(Vec::new())); @@ -454,9 +451,8 @@ mod tests { ); } - // Same, for `read_sectors_fua`: the forwarder must reach the inner - // source's FUA entry point, carrying the `fua` bit through. See - // docs/sector.md. + // Same, for `read_sectors_fua`: the forwarder must reach the inner source's FUA entry + // point, carrying the `fua` bit through. #[test] fn mut_ref_dyn_forwards_read_sectors_fua_to_the_inner_source() { let calls = Arc::new(Mutex::new(Vec::new())); @@ -481,9 +477,8 @@ mod tests { ); } - // The forwarding impl must delegate `set_speed`, which hides worse than - // the read methods because the trait default is also a no-op. See - // docs/sector.md. + // The forwarding impl must delegate `set_speed`, which hides worse than the read methods + // because the trait default is also a no-op. #[test] fn mut_ref_dyn_forwards_set_speed_to_the_inner_source() { let (mut spy, _reads, speeds, _bases) = Spy::new(0); diff --git a/src/sector/prefetched.rs b/src/sector/prefetched.rs index 5067a527..4bb35316 100644 --- a/src/sector/prefetched.rs +++ b/src/sector/prefetched.rs @@ -3,10 +3,9 @@ //! buffers on demand via a bounded channel, so disk+decrypt and //! demux run in parallel instead of serialized on one thread. //! -//! The producer thread ([`PrefetchedSectorSource::new`]) walks the -//! extent list in order, sending batches into a bounded channel; -//! sender drop reads as end-of-stream. `read_sectors` ignores its -//! `lba`/`count` args. See docs/prefetched.md for full rationale. +//! The producer thread ([`PrefetchedSectorSource::new`]) walks the extent list in order, +//! sending batches into a bounded channel; sender drop reads as end-of-stream. `read_sectors` +//! ignores its `lba`/`count` args. use crate::error::Result; use crate::event::{Event, EventKind}; @@ -65,9 +64,8 @@ impl PrefetchedSectorSource { /// composed read+decrypt stack — every byte the producer emits is /// what the consumer's demux will feed to its codec parsers. /// - /// Precondition: each extent's `sector_count` should be a multiple - /// of [`SECTOR_ALIGNMENT`], else [`Error::ExtentNotUnitAligned`] - /// surfaces through the channel. See docs/prefetched.md. + /// Precondition: each extent's `sector_count` should be a multiple of [`SECTOR_ALIGNMENT`], + /// else [`Error::ExtentNotUnitAligned`] surfaces through the channel. /// /// [`Error::ExtentNotUnitAligned`]: crate::error::Error::ExtentNotUnitAligned pub fn new<S>( @@ -258,11 +256,10 @@ impl PrefetchedSectorSource { }) } - /// Peel off the receivers for zero-copy pipeline mode: the caller - /// pulls buffers from `rx` and pushes drained ones back through - /// `recycle_tx`. Returns `(forward_rx, recycle_tx, shell)`; the - /// shell holds the producer's `JoinHandle` (drop it to join) plus - /// `total_sectors`. See docs/prefetched.md. + /// Peel off the receivers for zero-copy pipeline mode: the caller pulls buffers from `rx` + /// and pushes drained ones back through `recycle_tx`. Returns `(forward_rx, recycle_tx, + /// shell)`; the shell holds the producer's `JoinHandle` (drop it to join) plus + /// `total_sectors`. pub fn into_channels(self) -> (Receiver<Batch>, Sender<Vec<u8>>, PrefetchShell) { // MOVE the fields out, never clone: pre-1.0.0 code cloned + `forget`-ed // `self`, leaking endpoints that defeated disconnection shutdown and @@ -512,9 +509,8 @@ mod tests { } } - // Regression: dropping a `PrefetchedSectorSource` DIRECTLY, before - // extents are drained, must join the producer cleanly (old `Drop` - // joined before dropping `rx`/`recycle_tx`, deadlocking; see docs/prefetched.md). + // Regression: dropping a `PrefetchedSectorSource` DIRECTLY, before extents are drained, + // must join the producer cleanly. #[test] fn drop_undrained_source_joins_cleanly() { with_watchdog(Duration::from_secs(10), || { @@ -590,7 +586,6 @@ mod tests { // `unit_align == 0` must be rejected by the constructor, not a producer-thread // divide-by-zero panic (`remaining % 0`) misreported as DemuxThreadPanicked. - // See docs/prefetched.md. #[test] fn zero_unit_align_rejected() { let res = PrefetchedSectorSource::new_with_events( @@ -635,9 +630,9 @@ mod tests { }); } - // The producer-thread `event_fn` must fire a `BytesRead` event per batch, - // cumulative and non-decreasing, reaching the full extent size at EOF — - // the contract autorip's progress bar + stall watchdog depend on (docs/prefetched.md). + // The producer-thread `event_fn` must fire a `BytesRead` event per batch, cumulative and + // non-decreasing, reaching the full extent size at EOF — the contract autorip's progress + // bar + stall watchdog depend on. #[test] fn event_fn_fires_bytes_read_per_batch() { with_watchdog(Duration::from_secs(10), || { @@ -1032,9 +1027,8 @@ mod tests { }); } - // The underlying reader's error must propagate to the consumer as an - // error (not Ok(0)/EOF), with its ErrorKind surviving the channel - // round-trip (typed, not blanket-wrapped). See docs/prefetched.md. + // The underlying reader's error must propagate to the consumer as an error (not Ok(0)/EOF), + // with its ErrorKind surviving the channel round-trip (typed, not blanket-wrapped). #[test] fn reader_error_propagates_with_kind() { with_watchdog(Duration::from_secs(10), || { @@ -1055,9 +1049,9 @@ mod tests { }); } - // An inner source that answers a mid-extent read with `Ok(0)` has quit - // early: the producer must say so, not drop `tx` (which reads as clean - // EOF and lets `fill_extents` fabricate zeros for the rest). See docs/prefetched.md. + // An inner source that answers a mid-extent read with `Ok(0)` has quit early: the producer + // must say so, not drop `tx` (which reads as clean EOF and lets `fill_extents` fabricate + // zeros for the rest). struct QuitsEarlySource; impl SectorSource for QuitsEarlySource { fn read_sectors( @@ -1155,9 +1149,9 @@ mod tests { }); } - // Regression: too-small-buffer reads repeated past the pool depth (3) - // must NOT deadlock. Before the fix, each error path skipped recycling - // the received buffer, draining the pool by the 4th call. See docs/prefetched.md. + // Regression: too-small-buffer reads repeated past the pool depth (3) must NOT deadlock. + // Before the fix, each error path skipped recycling the received buffer, draining the pool + // by the 4th call. #[test] fn too_small_buffer_repeated_does_not_deadlock_pool() { with_watchdog(Duration::from_secs(10), || { @@ -1358,9 +1352,9 @@ mod tests { .expect_err("the producer's read failure must surface") } - // REGRESSION: an ordinary MEDIUM ERROR bad sector crossing the prefetch - // channel must NOT classify as a SCSI transport failure (old blanket - // `Error::IoError` wrap made it match the wedged-USB-bridge arm). See docs/prefetched.md. + // REGRESSION: an ordinary MEDIUM ERROR bad sector crossing the prefetch channel must NOT + // classify as a SCSI transport failure (old blanket `Error::IoError` wrap made it match the + // wedged-USB-bridge arm). #[test] fn bad_sector_across_channel_is_not_a_transport_failure() { with_watchdog(Duration::from_secs(10), || { diff --git a/src/session.rs b/src/session.rs index dfb68202..c3c44cfa 100644 --- a/src/session.rs +++ b/src/session.rs @@ -6,8 +6,6 @@ //! `Drive::drop`) and, after [`DiscSession::scan`], the resulting [`Disc`]. //! libfreemkv resolves no keys and reads no keydb: the consumer supplies its //! own key material via [`KeySpec`], forwarded to [`ScanOptions`] at scan time. -//! -//! See docs/session.md for the lifecycle-split rationale and key-forwarding contract. use crate::aacs::trace::ResolutionTrace; use crate::disc::{Disc, DiscId, DriveCredentials, ScanOptions}; @@ -54,8 +52,6 @@ pub struct ResolvedKeys { /// file-backed [`SectorSource`] from [`scan_iso`]), runs the ordered `sources` /// first-valid-wins, banks any winning unit keys onto `disc`, and builds the /// read-time [`KeyFetch`] used for later on-decrypt-miss fetches. -/// -/// See docs/session.md for the exact step-by-step breakdown. pub fn resolve_keys_for( reader: &mut dyn SectorSource, disc: &mut Disc, @@ -331,8 +327,8 @@ impl DiscSession { /// /// # Errors /// - /// [`Error::DeviceNotReady`] when the drive is no longer held — reachable - /// through ordinary use (see docs/session.md), not just caller error. + /// [`Error::DeviceNotReady`] when the drive is no longer held — reachable through ordinary + /// use, not just caller error. pub fn into_drive(self) -> Result<Drive> { self.drive.ok_or_else(|| Error::DeviceNotReady { path: self.device.clone(), @@ -368,9 +364,8 @@ impl DiscSession { self.reader.take() } - // Test-only: build a session over an injected reader + already-scanned disc - // without opening a live Drive, to exercise the mux/resolve_keys test paths. - // See docs/session.md for the full rationale. + // Test-only: build a session over an injected reader + already-scanned disc without opening + // a live Drive, to exercise the mux/resolve_keys test paths. #[cfg(test)] pub(crate) fn from_parts_for_test( disc: Option<Disc>, @@ -389,7 +384,7 @@ impl DiscSession { // Test-only: build a session that OWNS a (mock-backed) `Drive` so the // stage/into_reader/into_drive lifecycle can be exercised (unreachable via - // `from_parts_for_test`, which holds no drive). See docs/session.md. + // `from_parts_for_test`, which holds no drive). #[cfg(test)] pub(crate) fn from_drive_for_test(drive: Drive) -> DiscSession { let device = drive.device_path().to_string(); @@ -412,8 +407,6 @@ impl DiscSession { /// No SCSI, no handshake, no key resolution beyond what `opts` already /// carries. The returned reader is a fresh handle at the start of the image, /// reusable by callers that need to sample ciphertext or feed a mux. -/// -/// See docs/session.md for the full rationale. pub fn scan_iso(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorSource>)> { let mut reader = FileSectorSource::open(path)?; let capacity = reader.capacity_sectors(); @@ -435,8 +428,6 @@ const AACS_PROBE_UNITS: usize = 8; /// /// * none need decryption → `encrypted` is forced false, reason logged. /// * any unit does → [`Error::DirImageEncrypted`] (`dir://` doesn't support it). -/// -/// See docs/session.md for the full rationale. pub fn scan_dir(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorSource>)> { let mut reader = crate::dirimage::DirImage::open(path)?; let capacity = reader.capacity_sectors(); @@ -446,9 +437,9 @@ pub fn scan_dir(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorS Ok((disc, Box::new(reader))) } -// Re-judge a FOLDER's encryption verdict from its CONTENT (tree shape alone -// can be wrong for an already-decrypted folder that kept `AACS/`). Shared by -// scan_dir and the dir:// path in mux::resolve; see docs/session.md. +// Re-judge a FOLDER's encryption verdict from its CONTENT (tree shape alone can be wrong for an +// already-decrypted folder that kept `AACS/`). Shared by scan_dir and the dir:// path in +// mux::resolve. pub(crate) fn apply_folder_encryption_verdict( reader: &mut dyn SectorSource, disc: &mut Disc, diff --git a/src/testlog.rs b/src/testlog.rs index 93ee3450..cbe0cd6d 100644 --- a/src/testlog.rs +++ b/src/testlog.rs @@ -5,8 +5,6 @@ //! to keep this crate's one dev-dependency; installed once globally (see //! [`capture`]) and routes each event to the emitting thread's own sink so //! `cargo test`'s parallel harness can't cross-contaminate captures. -//! -//! See docs/testlog.md for the full rationale. #![cfg(test)] @@ -123,9 +121,8 @@ fn install() { }); } -// Run `f` with every `tracing` event it emits on this thread captured; -// returns `f`'s value alongside the events, in emission order. One global -// subscriber, not scoped `with_default` — see docs/testlog.md for why. +// Run `f` with every `tracing` event it emits on this thread captured; returns `f`'s value +// alongside the events, in emission order. One global subscriber, not scoped `with_default` pub(crate) fn capture<T>(f: impl FnOnce() -> T) -> (T, Vec<CapturedEvent>) { install(); let sink: Sink = Arc::default(); @@ -141,9 +138,8 @@ pub(crate) fn capture<T>(f: impl FnOnce() -> T) -> (T, Vec<CapturedEvent>) { mod tests { use super::*; - // The capture must actually see events and field values, not silently - // record nothing (see docs/testlog.md). Mutation: `enabled()` returning - // false, or `event()` dropping fields, fails here. + // The capture must actually see events and field values, not silently record nothing. + // Mutation: `enabled()` returning false, or `event()` dropping fields, fails here. #[test] fn capture_records_target_level_and_fields() { let ((), events) = capture(|| { diff --git a/src/udf.rs b/src/udf.rs index a7dedfd2..8f7ad307 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -5,16 +5,13 @@ //! Partition → File Set Descriptor → Root Directory ICB → Directory data //! → BDMV/PLAYLIST/*.mpls, BDMV/CLIPINF/*.clpi. Each step reads one or two //! sectors; no bulk reads needed. -//! -//! See docs/udf.md for the full pointer chain, ICB layout, and references. use crate::error::{Error, Result}; use crate::sector::SectorSource; use std::collections::HashSet; -// Cap on a single unbounded metadata file read (`read_file`); bounds the -// allocation a crafted ICB info_length/extent length can force. See -// docs/udf.md for the AACS MKB exception and the 0.31.0 regression. +// Cap on a single unbounded metadata file read (`read_file`); bounds the allocation a crafted +// ICB info_length/extent length can force. const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; // Cap on a single directory's on-disc data, well above any legitimate BD-ROM @@ -335,8 +332,8 @@ impl UdfFs { /// - UDF structure (AVDP, VDS, metadata partition, directories) /// - every non-STREAM file the tree walk reaches that is <= 50 MB /// - /// `STREAM` directories (case-insensitive) are not descended, and files - /// over 50 MB are skipped. See docs/udf.md for the full skip policy. + /// `STREAM` directories (case-insensitive) are not descended, and files over 50 MB are + /// skipped. pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> { let mut ranges = Vec::new(); @@ -411,9 +408,8 @@ impl UdfFs { }) } - // Returns the file's first RECORDED extent (not extents.first(), which may - // be an unrecorded type-1 hole) — see docs/udf.md for why file_start_lba - // and ifo.rs's VOB extent math depend on that distinction. + // Returns the file's first RECORDED extent (not extents.first(), which may be an unrecorded + // type-1 hole). fn read_icb_extent(&self, reader: &mut dyn SectorSource, meta_lba: u32) -> Result<IcbExtent> { let extents = self.read_icb_extents(reader, meta_lba)?; extents @@ -430,9 +426,8 @@ impl UdfFs { }) } - // If this ICB embeds its data inline (ICB Tag flags low 3 bits == 3), return - // those bytes; `Ok(None)` for the normal extent-backed case. See docs/udf.md - // for why read_file needs this (tiny AACS *.inf files) and the 0.31.0 regression. + // If this ICB embeds its data inline (ICB Tag flags low 3 bits == 3), return those bytes; + // `Ok(None)` for the normal extent-backed case. fn read_inline_data( &self, reader: &mut dyn SectorSource, @@ -668,9 +663,8 @@ impl UdfFs { self.read_inline_data(reader, meta_lba) } - // Absolute disc extents (absolute_lba, byte_length) for the ICB at meta_lba, - // keyed by ICB LBA rather than path. Unrecorded extents are kept but flagged - // — see docs/udf.md for why both the extent and the flag matter here. + // Absolute disc extents (absolute_lba, byte_length) for the ICB at meta_lba, keyed by ICB + // LBA rather than path. Unrecorded extents are kept but flagged. pub(crate) fn extents_abs_at( &self, reader: &mut dyn SectorSource, @@ -700,11 +694,8 @@ impl UdfFs { /// caller that will READ those sectors as the file's content — a title's /// play plan. /// - /// Refuses, with [`Error::UdfUnrecordedExtent`], a file containing an - /// unrecorded (ECMA-167 4/14.14.1.1 type-1/type-2) extent — see docs/udf.md - /// for why. Callers that only need a byte-space ADDRESS MAP (never reading - /// the sectors as content) want - /// [`file_extents_addressing`](Self::file_extents_addressing) instead. + /// Refuses, with [`Error::UdfUnrecordedExtent`], a file containing an unrecorded (ECMA-167 + /// 4/14.14.1.1 type-1/type-2) extent. pub fn file_extents( &self, reader: &mut dyn SectorSource, @@ -757,9 +748,8 @@ impl UdfFs { }) } - // Absolute disc extents (absolute_lba, sector_count) for a file, for a - // caller using the list purely as a byte-space address map — never reading - // the sectors as content. See docs/udf.md for why this stays pub(crate). + // Absolute disc extents (absolute_lba, sector_count) for a file, for a caller using the + // list purely as a byte-space address map — never reading the sectors as content. pub(crate) fn file_extents_addressing( &self, reader: &mut dyn SectorSource, @@ -1025,9 +1015,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> { }) } -// UDF 2.50 2.2.10 Metadata File Location: partition-relative block of the -// Metadata File's File Entry, at offset 40 of the Type 2 map at `map`. None -// if the map doesn't fit, or isn't "*UDF Metadata Partition" — see docs/udf.md. +// UDF 2.50 2.2.10 Metadata File Location: partition-relative block of the Metadata File's File +// Entry, at offset 40 of the Type 2 map at `map`. None if the map doesn't fit, or isn't "*UDF +// Metadata Partition". fn metadata_file_location(lvd: &[u8; 2048], map: usize) -> Option<u32> { // ECMA-167 3/10.7.3 fixes the Type 2 map at 64 bytes. if map.checked_add(64)? > lvd.len() { @@ -1055,9 +1045,9 @@ const MAX_DIR_DEPTH: u32 = 8; // terminate in microseconds instead of an astronomical number of visits. const MAX_TOTAL_DIR_ENTRIES: usize = 100_000; -// Recursive UDF directory-tree walk (up to MAX_DIR_DEPTH); `budget` caps FIDs -// visited tree-wide, `visited` detects ICB-LBA cycles. Wide arg list is -// inherent to the walk, not a refactor smell. See docs/udf.md for detail. +// Recursive UDF directory-tree walk (up to MAX_DIR_DEPTH); `budget` caps FIDs visited +// tree-wide, `visited` detects ICB-LBA cycles. Wide arg list is inherent to the walk, not a +// refactor smell. #[allow(clippy::only_used_in_recursion)] #[allow(clippy::too_many_arguments)] fn read_directory( @@ -1389,8 +1379,7 @@ fn read_file_size(reader: &mut dyn SectorSource, meta_start: u32, meta_lba: u32) } } -// Parse a UDF filename: first byte is a compression ID (8 = ASCII, -// 16 = UTF-16BE); see docs/udf.md for the encoding table. +// Parse a UDF filename: first byte is a compression ID (8 = ASCII, 16 = UTF-16BE). pub(crate) fn parse_udf_name(data: &[u8]) -> String { if data.is_empty() { return String::new(); @@ -1497,9 +1486,8 @@ pub(crate) fn parse_dstring_for_test(data: &[u8]) -> String { parse_dstring(data) } -// Buffered sector reader — coalesces single-sector reads into `batch`-sized -// SCSI commands, since per-command latency dominates on USB drives. See -// docs/udf.md "Buffered Sector Reads". +// Buffered sector reader — coalesces single-sector reads into `batch`-sized SCSI commands, +// since per-command latency dominates on USB drives. pub(crate) struct BufferedSectorReader<'a> { inner: &'a mut dyn SectorSource, cache_start: u32, @@ -1558,9 +1546,9 @@ impl BufferedSectorReader<'_> { self.cache_sectors = offset; } - // Pre-read multiple sector ranges into the permanent per-sector HashMap cache - // (bulk-loads AACS/MPLS/CLPI/META before scanning), capped at - // MAX_PREFETCH_SECTORS to bound RAM against a crafted UDF. See docs/udf.md. + // Pre-read multiple sector ranges into the permanent per-sector HashMap cache (bulk-loads + // AACS/MPLS/CLPI/META before scanning), capped at MAX_PREFETCH_SECTORS to bound RAM against + // a crafted UDF. pub(crate) fn prefetch_ranges(&mut self, ranges: &[(u32, u32)]) { // 2048 bytes/sector → 512 Ki sectors ≈ 1 GiB of permanent cache. const MAX_PREFETCH_SECTORS: u64 = 512 * 1024; @@ -1778,8 +1766,8 @@ mod tests { s } - // Build a tag-261/266 entry with short ADs and every disc-controlled field - // of the descriptor area exposed (l_ea/l_ad/extra) — see docs/udf.md. + // Build a tag-261/266 entry with short ADs and every disc-controlled field of the + // descriptor area exposed (l_ea/l_ad/extra). fn build_entry_ads( tag: u16, l_ea: usize, @@ -1810,9 +1798,8 @@ mod tests { s } - // file_start_lba must skip a leading UNRECORDED extent and report where - // the file's DATA starts, not where the hole's space lives — see - // docs/udf.md "read_icb_extent(): first RECORDED extent" for why. + // file_start_lba must skip a leading UNRECORDED extent and report where the file's DATA + // starts, not where the hole's space lives. #[test] fn file_start_lba_skips_a_leading_unrecorded_extent() { use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, lay_dir}; @@ -2423,9 +2410,8 @@ mod tests { assert!(dir.is_dir); } - // ---- spec-boundary coverage for AD strides, flags, FIDs ---- - // Build a tag-266 ICB with EXTENDED ADs (20 bytes each, LBA at offset - // +12); sets ICB Tag flags to 2 = Extended AD. See docs/udf.md. + // ---- spec-boundary coverage for AD strides, flags, FIDs ---- Build a tag-266 ICB with + // EXTENDED ADs (20 bytes each, LBA at offset +12); sets ICB Tag flags to 2 = Extended AD. fn build_efe_ext(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] { let mut s = [0u8; 2048]; s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag @@ -2521,9 +2507,8 @@ mod tests { ); } - // AD type 3 is EMBEDDED data, not a descriptor list; the old `_ => 8` - // fallback misdecoded it as an extent, so a rip would read the wrong - // sector at rc=0. See docs/udf.md for why the fixture avoids garbage bytes. + // AD type 3 is EMBEDDED data, not a descriptor list; the old `_ => 8` fallback misdecoded + // it as an extent, so a rip would read the wrong sector at rc=0. #[test] fn icb_extents_embedded_data_is_refused_not_decoded_as_descriptors() { let mut icb = build_efe(2048, &[(0, 2048, 999)]); @@ -2560,9 +2545,9 @@ mod tests { ); } - // A ZERO-LENGTH unrecorded descriptor must not cost the file its plan: it - // displaces nothing, so refusing on `!recorded` alone would silently drop - // whole titles that ripped fine before. See docs/udf.md. + // A ZERO-LENGTH unrecorded descriptor must not cost the file its plan: it displaces + // nothing, so refusing on `!recorded` alone would silently drop whole titles that ripped + // fine before. #[test] fn file_extents_accepts_a_zero_length_unrecorded_extent() { // A zero-length type-1 hole, then the file's real 4096 bytes. @@ -2650,9 +2635,9 @@ mod tests { ); } - // file_extents builds the extent list a title is actually ripped through; - // its tuple can't flag a hole, so folding or dropping one is both wrong — - // refusal is the only truthful answer. See docs/udf.md. + // file_extents builds the extent list a title is actually ripped through; its tuple can't + // flag a hole, so folding or dropping one is both wrong — refusal is the only truthful + // answer. #[test] fn file_extents_refuses_a_file_with_an_unrecorded_extent() { let icb = build_efe( @@ -3412,8 +3397,8 @@ mod tests { ); } - // prefetch's start_lba + offset must not overflow past u32::MAX for a - // disc-declared partition near the top of LBA space. See docs/udf.md. + // prefetch's start_lba + offset must not overflow past u32::MAX for a disc-declared + // partition near the top of LBA space. #[test] fn prefetch_near_u32_max_does_not_overflow() { let mut inner = MapReader::new(); @@ -3691,9 +3676,8 @@ mod tests { assert_eq!(fs.root.entries[0].name, "INDEX.BDMV"); } - // An anchor whose recorded VDS extent passes every SHAPE check but holds - // no sequence must still mount, by sweeping the customary location — shape - // alone can't prove content. See docs/udf.md. + // An anchor whose recorded VDS extent passes every SHAPE check but holds no sequence must + // still mount, by sweeping the customary location — shape alone can't prove content. #[test] fn a_shape_valid_vds_pointer_that_holds_no_sequence_falls_back_to_the_customary_location() { use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir}; @@ -3728,9 +3712,8 @@ mod tests { assert_eq!(fs.root.entries[0].name, "INDEX.BDMV"); } - // The recorded extent and the customary fallback are two SWEEPS, not two - // locations: de-duplicating them on start LBA alone would throw the wider - // sweep away. See docs/udf.md for the fixture layout. + // The recorded extent and the customary fallback are two SWEEPS, not two locations: + // de-duplicating them on start LBA alone would throw the wider sweep away. #[test] fn a_recorded_vds_narrower_than_the_customary_window_still_gets_the_wider_sweep() { use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir}; @@ -3814,9 +3797,8 @@ mod tests { decoy_meta_fe: Option<u32>, } - // A conformant UDF 2.50 metadata-partition volume with the Metadata File - // FE where the partition map says it is. See docs/udf.md for why the - // extent length/position use distinct, boundary-exact byte patterns. + // A conformant UDF 2.50 metadata-partition volume with the Metadata File FE where the + // partition map says it is. fn conformant_meta_vol() -> MetaVol { MetaVol { pm1_type: 1, @@ -3839,9 +3821,9 @@ mod tests { const MV_VOLUME_ID: &str = "FREEMKV-META"; const MV_FILE_SIZE: u64 = 100; - // Partition-relative LBAs of a SECOND, decoy tree rooted at block 0 of the - // physical partition. Distinguishes "used the metadata partition" from - // "fell back to physical" by CONTENT, not an error code. See docs/udf.md. + // Partition-relative LBAs of a SECOND, decoy tree rooted at block 0 of the physical + // partition. Distinguishes "used the metadata partition" from "fell back to physical" by + // CONTENT, not an error code. const MV_FB_ROOT_ICB: u32 = 40; const MV_FB_ROOT_DATA: u32 = 41; const MV_FB_FILE_ICB: u32 = 42; @@ -3979,9 +3961,9 @@ mod tests { (disc, meta_start) } - // A read fault while locating the Metadata File must surface as a read - // error, NOT "this is not a UDF disc" — mux::resolve memoises the latter - // per-disc and would silently drop content. See docs/udf.md. + // A read fault while locating the Metadata File must surface as a read error, NOT "this is + // not a UDF disc" — mux::resolve memoises the latter per-disc and would silently drop + // content. #[test] fn a_read_fault_locating_the_metadata_file_is_not_reported_as_a_non_udf_disc() { use fixture::PART_START; @@ -4300,9 +4282,7 @@ mod tests { assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]); } - // ---- directory-descriptor boundary coverage: every field below is - // disc-controlled and can push the parser off a buffer or turn a failure - // into a plausible-looking success. Offsets/rationale in docs/udf.md. + // Exercise directory-descriptor boundaries with untrusted field lengths and offsets. fn build_dir_icb_tagged(tag: u16, l_ea: usize, raw_len: u32, ad_pos: u32) -> [u8; 2048] { let mut icb = [0u8; 2048]; icb[0..2].copy_from_slice(&tag.to_le_bytes()); diff --git a/src/unlock_bridge.rs b/src/unlock_bridge.rs index 876e34fb..cff95db7 100644 --- a/src/unlock_bridge.rs +++ b/src/unlock_bridge.rs @@ -88,8 +88,8 @@ pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec<fu:: .collect() } -// (matched_name, result): `Ok(Some)` = that unlocker unlocked; `Ok(None)` = -// nobody claimed the drive; `Err(Transport)` = dead bus. See docs/unlock-bridge.md. +// (matched_name, result): `Ok(Some)` = that unlocker unlocked; `Ok(None)` = nobody claimed the +// drive; `Err(Transport)` = dead bus. type Dispatch = ( &'static str, std::result::Result<Option<fu::Unlocked>, fu::UnlockError>, diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index 515beb36..3ef506f2 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -551,8 +551,7 @@ fn css_roundtrip_multiple_keys() { // ── CSS keyless recovery tests ───────────────────────────────────────────── -// Attempt keyless recovery on synthetically scrambled sectors; see -// docs/css-keyless.md for why synthetic data may not converge. +// Attempt keyless recovery on synthetically scrambled sectors. #[test] fn css_keyless_recovery_validates_cracked_key() { let candidates: &[([u8; 5], [u8; 5])] = &[ diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 08a609b1..73817362 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -588,7 +588,6 @@ fn aacs_dir_alone_marks_the_disc_encrypted_and_reports_the_capture_error() { } // Capacity/layer derivation from sector count; wrong scale = wrong ISO length. -// See docs/disc-tests.md — capacity_bytes and layers threshold rationale. #[test] fn scan_image_reports_capacity_in_bytes_and_the_layer_count() { let opts = ScanOptions::default(); diff --git a/tests/fvi_pipeline.rs b/tests/fvi_pipeline.rs index a1eedeaa..794c6293 100644 --- a/tests/fvi_pipeline.rs +++ b/tests/fvi_pipeline.rs @@ -208,7 +208,7 @@ fn fvi_sink_indexes_real_mpeg2_pipeline_output() { let text = std::fs::read_to_string(&path).unwrap(); let mut lines = text.lines(); - // ── Header line (docs/FVI_FORMAT.md v1 schema) ───────────────────────────── + // ── Header line ─────────────────────────────. let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap(); assert_eq!(header["format"], "freemkv/video-index"); assert_eq!(header["fvi_version"], 1); diff --git a/tests/scsi_error_decoding.rs b/tests/scsi_error_decoding.rs index 19a9778d..a7d477c5 100644 --- a/tests/scsi_error_decoding.rs +++ b/tests/scsi_error_decoding.rs @@ -6,8 +6,6 @@ //! ([`Error::is_scsi_transport_failure`] true); SCSI-level failure (CHECK //! CONDITION) → `Error::ScsiError` with parsed key/ASC/ASCQ, checked via //! [`Error::is_marginal_read`] / [`ScsiSense::is_*`]. -//! -//! See docs/scsi-error-decoding.md for rationale and history. use libfreemkv::error::Error; use libfreemkv::scsi::{ @@ -17,9 +15,8 @@ use libfreemkv::scsi::{ SENSE_KEY_RECOVERED_ERROR, SENSE_KEY_UNIT_ATTENTION, ScsiResult, ScsiSense, ScsiTransport, }; -// A scripted ScsiTransport. Each `execute()` consumes the next entry -// from `script` and returns the corresponding outcome. -// See docs/scsi-error-decoding.md — MockTransport outcomes. +// A scripted ScsiTransport. Each `execute()` consumes the next entry from `script` and returns +// the corresponding outcome. struct MockTransport { script: Vec<MockOutcome>, next: usize,