Compare commits

...
13 Commits
Author SHA1 Message Date
Matthew Jackson 8d17904459 v1.6.2: bump version (freemkv-unlock git-pinned for the tag) 2026-08-08 22:18:28 -07:00
Matthew Jackson 1bdb8add2f Stop trusting a byte offset when the feed is not what was measured
A clip's span is measured over the title's full extents when the disc is
scanned, and a frame is placed by the offset it was read from. Those only
agree while the mux reads every byte the scan counted.

On a disc carrying a forensic segment it does not: the read plan omits the
units belonging to another device group, so fewer bytes are fed than the
spans describe, and the shortfall grows through the title. Every frame past
the first segment then looks earlier than it is — placed in a clip it did
not come from, or dropped at a join for failing marks it was never inside.
The spans still tile one another perfectly, so the check that asks whether
they can be trusted cannot see any of it.

The plan is now compared against the extents it was built from. When they
differ the spans are dropped and placement falls back to timestamps, which
is what that path is for and what the surrounding comment already promised
would happen when an offset stops meaning anything. Ordinary discs are
untouched: with no forensic segment the plan IS the extents.
2026-08-08 19:42:53 -07:00
Matthew Jackson c9562c07a3 Make four guards testable, and one guard see both spellings
The provenance guard knew one way to lose a frame's source offset —
writing it as absent. Omitting the field entirely does the same thing,
because the frame type fills it in by default, and the guard read straight
past that. A parser rewritten into the second spelling would have kept its
green light while its track silently went back to being placed by guesswork.
It now reports which spelling it found and where.

The title-count clamp was asserted over a fixture too small to hold more
titles than the cap allows, so the walk stopped when the buffer ran out and
the clamp was never what bounded it — the assertion held with the clamp
deleted. The fixture now carries more entries than the cap.

The subdirectory limit was checked by restating the constant's own
definition; the guard itself had never run, and deleting it changed
nothing. The limit is lowered under test so a real folder can exceed it,
and the test now walks one and requires the refusal.

One test also carried two unrelated grounding notes while the test they
described had none, so reading the note above a test told you about a
different one.
2026-08-08 19:06:37 -07:00
Matthew Jackson 7f9dcf1870 Keep both halves of a clip file that two play items share
A playlist may point at the same .m2ts from several play items — a
seamless split, a looped segment, multiple angles. The file has one set of
bytes, so it has one span, and the byte a frame was read from therefore
identifies the FILE, not which play item's range it falls in. Placement
took the first of them and judged every frame against its marks, so
everything past that range's end was treated as material the playlist
excludes and dropped: half the clip missing from the rip, with the
timeline still charged for its full duration.

The offset narrows a frame to the run; only its timestamp can finish the
job, and each play item carries its own marks. A frame is now matched
against the marks of the entry it actually falls in, falling back to the
first when it falls in none, which leaves genuinely-excluded material
dropped as before.

The test for this construction asserted only that such a playlist is
trusted, and never placed a frame from the second range — so the loss it
described in prose was invisible to it. It now places one from every range
and requires them all to survive.

The deferred-mux replay of buffered frames wrote them without their source
offset, quietly sending the head of every such title down the timestamp
heuristic the rest of this work exists to retire. It passes the offset
through now, and the frame writer that omits it is compiled out of the
library entirely: no production path can discard provenance any more.

Also: the AC-3 buffer-reuse test could not see the regression it named.
Feeding equal-sized packets, a fresh allocation per call yields the same
capacity as a reused one. It now feeds a large packet then a small one,
where only a reused buffer keeps the larger capacity.
2026-08-08 17:18:35 -07:00
Matthew Jackson 3c5c681399 Audit round: an ADTS frame that cannot hold its own CRC, and three drifts
An ADTS header that declares a CRC follows must be at least nine bytes —
seven of header plus the two the CRC occupies — because the declared frame
length counts them. The structural gate compared against a flat seven and
never read the bit that says whether a CRC is there at all, so a frame
whose own header describes something impossible was accepted and handed to
the muxer as decodable.

The pipeline's spawn doc named Sweep, and the thread Sweep would have
created, as callers to look for. Neither has been in this crate since the
recovery passes moved out. The same paragraph already records fixing this
once, for a different departed caller — it simply drifted again a sentence
later, so it now says to name callers that live here or name none.

Whether a disc is structurally AACS-encrypted was spelled out by hand in
both the fast identify and the full scan. They agreed today; nothing made
them agree tomorrow, and disagreeing would mean the same disc reported
encrypted by one path and clear by the other. There is one definition now,
and the comment that pointed at it by line number points at its name.

The AC-3 parser built a fresh buffer on every packet — of the order of a
hundred thousand times per title — to work around a borrow it cannot
avoid. The copy stays; the allocation does not. The buffer is now lent out
and handed back, and a test pins that, because reverting it would be
invisible in behaviour.

Left alone deliberately: send/send_with_halt and finish/finish_with_halt
look like one action under two names, and are not. After the consumer
fails, one must still accept items and the other must refuse them; that
difference is what stops a producer reading an entire disc for a write
that died on its first frame. Collapsing them was tried here and the
existing test caught it. Both now say so where the choice is made.
2026-08-08 16:55:55 -07:00
Matthew Jackson 43d9fb583a Add an error for an image shorter than its recovery data
A recovery resumed against an image that has been truncated since the
previous pass — a full disk, an interrupted transfer, a remount — cannot
repair it: the pass only revisits the ranges the recovery data calls bad,
so everything past the cut stays a hole while the counts still describe a
whole disc. There was no way to say that. Reusing the recovery-data error
would have been wrong, because the recovery data is intact; the image is
not.

Registered in the uniqueness list, the code table and the range check, so
a future variant cannot silently reuse 6015 or map to the wrong code.
2026-08-08 13:35:44 -07:00
Matthew Jackson 60ed45c047 changelog: 1.6.2 2026-08-08 08:53:13 -07:00
Matthew Jackson 07cc9fa5f9 ci: run the dependents' test suites, not just a type-check
The `consumers` job checked out all five dependents against this commit and
ran `cargo check --all-targets`. That proves they still COMPILE, which
catches a changed signature and nothing else. The failures worth catching
here keep every signature intact and change behaviour: the library still
builds, the dependent still builds, and the dependent's tests are what go
red. Those never ran.

Run their suites instead. Same checkouts, same patched paths — only the
verb changes.
2026-08-08 08:46:17 -07:00
Matthew Jackson 1e6bb8babd Give a frame that outruns its epoch's video a provisional offset
The demuxer interleaves, so at a boundary the streams do not reset on the
same frame: audio for the next segment can reach the muxer before the video
frame that opens the epoch it belongs to. Only the primary video track may
open one, so those frames rode the just-ended offset and landed a whole
segment in the past. Downstream the strictly-monotonic block nudge then
crushed the entire run onto one instant a tick apart, which is audible.

Such a frame is recognised on its OWN raw PTS, which within an epoch only
advances, so a large backward step is unambiguous — a different signal from
the shared frontier, which is what the old false-positive ratchet keyed on.
It then rides a provisional offset private to its track, computed the same
way the video path computes a real one, and drops it the moment the video
retires an epoch, so the run rejoins with no seam.

The offset is deliberately private: it never writes offset_ns, never
advances the frontier and never retires an epoch, so it cannot move the
video timeline. Letting a passive track open a REAL epoch was tried first
and inflated a 476.776 s title to 656.216 s, because every track meets a
boundary at its own pace and the video path rebased again on top.

Measured on a real DVD title with 8 cell boundaries: audio frames stamped
inside one cadence 120 -> 0, outsized gaps 7 -> 0, subtitle 1 -> 0, and the
video span byte-identical at 476.484. On the HD-DVD title the remaining
boundary gap on its first audio track falls from 0.999 s to 0.275 s.
Acceptance: 76 pass, 0 fail, 0 skip.
2026-08-08 00:48:10 -07:00
Matthew Jackson 64f894c9f3 Place a straggler by its own epoch's end, not the current frontier
A non-video frame can reach the muxer after the video that opened the next
epoch has already been processed. It carries an old-epoch PTS, so adding
the new offset flings it forward by a whole clip. The remap that exists to
catch this compared the frame against the CURRENT frontier and only
accepted it within one backstep, which is the wrong yardstick: by the time
a straggler is seen the frontier has moved on into a new epoch, and how far
below it the frame lands says nothing about where it belongs.

Retiring an epoch now records the frontier it closed at alongside its
offset, and a straggler is judged against the end of the epoch its PTS came
from. Keeping the whole history rather than one previous offset also fixes
the case a single `prev_offset_ns` cannot express at all: a frame from two
or more epochs back.

Measured on a real HD-DVD title whose second audio track put its final
packet at 12834.587 s in a 6434.100 s file. That frame sits 23 s below the
current frontier — outside the old window, so it was refused — but 0.15 s
below the end of the clip it actually came from. It now lands at 6417.216,
one 32 ms cadence step after its neighbour, and the track spans 6416.160
with no outsized gaps. The other two streams are unchanged.

A title that never rebases retires no epoch, so the lookup finds nothing
and the mapping is bit-identical to before.
2026-08-08 00:02:57 -07:00
Matthew Jackson 3820348d2f test: cover the 1.6.1 provenance work where it was assumed, not asserted
Four parsers (dvdsub, flac, lpcm, mpegaudio) stamp a source offset on
every frame but had no test that read one back, so a regression to
`source: None` would have been caught only by the brace-balanced audit
in codec/mod.rs — a lint, not a behavioural check. Each now asserts the
emitted frame carries the offset of the packet that supplied its first
byte.

The Blu-ray feed spans had no direct test at all. Add one that walks a
multi-item playlist and requires the spans to tile the feed with no gap
or overlap; it catches a one-sector-per-clip drift, which is exactly the
error class that would misattribute frames near a seam.

`no_provenance_still_places_by_marks` asserted only that placement
returned something, which passes for a frame placed in the wrong clip.
Its probe timestamp lands in an overlap between two clips in the real
mark table, so pinning one clip would assert a coin-flip; instead
require the offset to be one that a clip actually containing that
timestamp would produce.
2026-08-07 22:16:02 -07:00
Matthew Jackson 102ae82933 Test the DVD title table's sector-offset arithmetic
The TT_SRPT pointer at 0xC4 is a SECTOR offset from the start of
VIDEO_TS.IFO. Reading it as a byte offset lands 1/2048th of the way in and
does not error — it silently returns a different title list, which is how
a disc enumerating 38 titles and an image enumerating 10 looked like a
scan difference rather than a failure.

The table already had tests for its entry layout, dedup and the 99-title
clamp. What it did not have was anything pinning the offset unit itself,
or the grouping of titles by title set, or a truncated table keeping the
rows it can read rather than failing a whole disc.
2026-08-07 22:01:37 -07:00
Matthew Jackson 9d05f54ddb restore freemkv-unlock path dep for local dev (post-v1.6.1) 2026-08-07 19:08:46 -07:00
23 changed files with 1103 additions and 77 deletions
+9 -5
View File
@@ -187,13 +187,17 @@ jobs:
freemkv
autorip
bdemu
- run: cargo check --all-targets
# `cargo check` alone only proves the dependents still COMPILE against
# this commit. It cannot see a behavioural change — the library keeps its
# signatures and a dependent's tests start failing. That is the shape of
# every defect worth catching here, so run their suites too.
- run: cargo test --tests
working-directory: freemkv-keysources
- run: cargo check --all-targets
- run: cargo test --tests
working-directory: freemkv-engine
- run: cargo check --all-targets
- run: cargo test --tests
working-directory: freemkv
- run: cargo check --all-targets
- run: cargo test --tests
working-directory: autorip
- run: cargo check --all-targets
- run: cargo test --tests
working-directory: bdemu
+17
View File
@@ -1,5 +1,22 @@
# Changelog
## [1.6.2] — 2026-08-08
### Fixed
- **A stray moment of sound at the end of an HD-DVD title, and a click at every
chapter break on a DVD.** Both came from the same thing. A disc's sound and
picture do not arrive in lockstep, and where a title is stitched from
segments, a few frames of sound can reach the muxer just before or just after
the picture that marks the join. Those frames were timed against the wrong
segment. On one HD-DVD title a single trailing sound frame was placed at
3h33m in a 1h47m film. On a DVD, roughly half a second of sound was squeezed
into an instant at each of eight chapter breaks — audible as a click, eight
times in an eight-minute title. Sound is now timed against the segment it
belongs to. Measured on both discs: the stray frame lands one frame after its
neighbour, and the DVD's collapsed runs and jumps are gone, with picture
timing unchanged to the millisecond. Blu-ray was never affected.
## [1.6.1] — 2026-08-07
### Added
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.6.1"
version = "1.6.2"
edition = "2024"
rust-version = "1.97"
license = "MIT"
@@ -28,7 +28,7 @@ cbc = "0.1"
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.1" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.2" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
+43
View File
@@ -695,6 +695,49 @@ mod tests {
// ── `decrypt_sectors_in_content` (now a legacy alias of `decrypt_sectors`) ──
/// `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.
#[test]
fn a_forensic_read_plan_drops_units_the_full_extents_include() {
let full = vec![crate::disc::Extent {
start_lba: 1000,
sector_count: 60,
}];
// No forensic segment: the plan IS the extents, byte for byte, so
// provenance stays trustworthy on an ordinary disc.
let plain = AacsKeyMap::from_ranges_phased(vec![(1000, 1060, 5, Phase::All)]);
assert_eq!(
plain.read_plan(&full, 3),
full,
"a non-forensic map must return the extents unchanged"
);
// With alternate phases, units are omitted — fewer sectors are read
// than the spans describe.
let phased = AacsKeyMap::from_ranges_phased(vec![(1000, 1060, 5, Phase::Even)]);
let plan = phased.read_plan(&full, 3);
let planned: u32 = plan.iter().map(|e| e.sector_count).sum();
let whole: u32 = full.iter().map(|e| e.sector_count).sum();
assert!(
planned < whole,
"a forensic segment must drop units: planned {planned} of {whole}"
);
assert_ne!(
plan, full,
"the plan differs from the extents, which is exactly what the mux \
detects before deciding whether a byte offset means anything"
);
}
#[test]
fn content_gate_none_keys_is_noop() {
let mut keys = DecryptKeys::None;
+42 -15
View File
@@ -57,7 +57,15 @@ const MAX_CS0_NAME_BYTES: usize = 254;
/// 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 the previous test asserted arithmetic about the
/// constant instead and would have passed with the guard deleted. The
/// production value is unchanged.
#[cfg(not(test))]
const MAX_SUBDIRS: usize = (u16::MAX - 1) as usize;
#[cfg(test)]
const MAX_SUBDIRS: usize = 4;
/// Largest image this planner will synthesize, in sectors (128 GiB).
///
@@ -859,16 +867,6 @@ mod tests {
assert!(!is_excluded("VTS_01_1.VOB"));
}
/// A name too long for the FID's one-byte length field is refused by the
/// PLANNER, on a real folder.
///
/// 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.
/// Two host names that the READER collapses into one must be refused by
/// the planner, not written into the image.
///
@@ -912,6 +910,16 @@ mod tests {
);
}
/// A name too long for the FID's one-byte length field is refused by the
/// PLANNER, on a real folder.
///
/// 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.
#[test]
fn an_over_long_name_is_refused_by_the_planner() {
let dir = std::env::temp_dir().join(format!(
@@ -971,11 +979,30 @@ mod tests {
/// so this pins the arithmetic relationship the guard relies on — and says
/// plainly that it does NOT exercise `walk`.
#[test]
fn the_subdir_cap_keeps_the_link_count_representable() {
assert_eq!(
(MAX_SUBDIRS as u16).checked_add(1),
Some(u16::MAX),
"the largest permitted fan-out must still fit the link count"
fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() {
// Executes the guard, rather than restating the constant. A directory's
// File Entry records its link count in 16 bits — one per child
// directory plus one for its own entry in the parent — so exceeding it
// would wrap the count and produce an image whose directory structure
// lies about itself.
let dir = std::env::temp_dir().join(format!("fmkv-fanout-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..=MAX_SUBDIRS {
std::fs::create_dir_all(dir.join(format!("d{i}"))).unwrap();
}
let err = plan(&dir).expect_err("more subdirectories than the link count can represent");
assert!(
matches!(err, Error::DirImageFanout { .. }),
"expected DirImageFanout, got {err:?}"
);
// And the real production value is what ships: one per child plus the
// parent's own entry must still fit in the 16-bit field.
#[cfg(not(test))]
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
let _ = std::fs::remove_dir_all(&dir);
}
}
+108
View File
@@ -914,3 +914,111 @@ fn dump_title_sets_for_an_image() {
println!(" vts={} titles={}", ts.vts_number, ts.titles.len());
}
}
/// Build an MPLS with N PlayItems, each naming its own clip.
fn multi_item_mpls(clip_ids: &[&[u8; 5]]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&40u32.to_be_bytes());
buf.extend_from_slice(&[0u8; 28]);
let pl = buf.len();
buf.extend_from_slice(&[0u8; 4]);
buf.extend_from_slice(&[0u8; 2]);
buf.extend_from_slice(&(clip_ids.len() as u16).to_be_bytes());
buf.extend_from_slice(&[0u8; 2]);
for id in clip_ids {
let mut item = Vec::new();
item.extend_from_slice(*id);
item.extend_from_slice(b"M2TS");
item.push(0);
item.extend_from_slice(&[0u8; 2]);
item.extend_from_slice(&0u32.to_be_bytes());
item.extend_from_slice(&(45_000u32 * 120).to_be_bytes());
item.extend_from_slice(&[0u8; 8]);
item.push(0);
item.push(0);
item.extend_from_slice(&[0u8; 2]);
item.extend_from_slice(&16u16.to_be_bytes());
item.extend_from_slice(&[0u8; 16]);
buf.extend_from_slice(&(item.len() as u16).to_be_bytes());
buf.extend_from_slice(&item);
}
let pl_len = (buf.len() - pl - 4) as u32;
buf[pl..pl + 4].copy_from_slice(&pl_len.to_be_bytes());
let mark_start = buf.len() as u32;
buf[12..16].copy_from_slice(&mark_start.to_be_bytes());
buf.extend_from_slice(&2u32.to_be_bytes());
buf.extend_from_slice(&0u16.to_be_bytes());
buf
}
/// `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.
#[test]
fn a_multi_clip_playlist_produces_feed_spans_that_tile_the_feed() {
let s = Scratch::new("spans");
let packets = 4096u32;
let ids: [&[u8; 5]; 3] = [b"00000", b"00001", b"00002"];
let mut m2ts = vec![0x5Au8; packets as usize * 192];
for p in m2ts.chunks_mut(192) {
p[0] = 0x00;
p[4] = 0x47;
}
s.file("BDMV/index.bdmv", &pattern(1, 64));
s.file("BDMV/PLAYLIST/00000.mpls", &multi_item_mpls(&ids));
for id in ids {
let name = std::str::from_utf8(id).unwrap();
s.file(&format!("BDMV/CLIPINF/{name}.clpi"), &minimal_clpi(packets));
s.file(&format!("BDMV/STREAM/{name}.m2ts"), &m2ts);
}
let (disc, _reader) =
crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap();
let title = disc.titles.first().expect("the playlist produces a title");
assert_eq!(title.clips.len(), 3, "all three PlayItems are kept");
// Every clip must carry a span, or provenance is simply off for this title.
let spans: Vec<(u64, u64)> = title
.clips
.iter()
.map(|c| c.feed_span.expect("every clip carries a feed span"))
.collect();
// They must tile from 0 with no gap and no overlap: `SeamPlan::from_clips`
// refuses anything else, so a producer that drifts here silently disables
// the feature rather than failing.
let mut expect = 0u64;
for (i, &(start, end)) in spans.iter().enumerate() {
assert_eq!(
start,
expect,
"clip {i} starts where clip {} ended",
i.saturating_sub(1)
);
assert!(end > start, "clip {i} spans no bytes");
expect = end;
}
// And the tiling must describe the bytes the muxer will actually be fed:
// the concatenation of every clip's extents.
let feed_bytes: u64 = title
.extents
.iter()
.map(|e| e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64)
.sum();
assert_eq!(
expect, feed_bytes,
"the spans must cover exactly the feed, or a byte offset lands in the wrong clip"
);
}
+21 -4
View File
@@ -479,6 +479,25 @@ pub struct Extent {
/// Union a set of extents into sorted, merged, disjoint `(start_lba,
/// sector_count)` ranges — the pure, testable core of
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
/// Is this disc structurally AACS-encrypted — i.e. does it carry an AACS
/// directory?
///
/// THE ONE definition of that question. `Disc::identify` (the fast path, which
/// only reads the filesystem) and `Disc::scan_with` (the full scan) both need
/// it, and both used to spell the same two `find_dir` calls out 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).
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()
}
fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u32)> {
let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect();
ranges.sort_by_key(|r| r.0);
@@ -1628,8 +1647,7 @@ impl Disc {
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
let encrypted = aacs_dir_present(&udf_fs);
let layers = if capacity > 24_000_000 { 2 } else { 1 };
Ok(DiscId {
@@ -2015,8 +2033,7 @@ impl Disc {
let scan_with_t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
// 2. Resolve encryption (AACS, CSS, or none)
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
let encrypted = aacs_dir_present(&udf_fs);
let (aacs, aacs_error) = if !encrypted {
(None, None)
+19
View File
@@ -62,6 +62,7 @@ pub const E_MAPFILE_INVALID: u16 = 6011;
pub const E_SELECTION_PID_UNKNOWN: u16 = 6014;
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
pub const E_UDF_NOT_FILESYSTEM: u16 = 6013;
pub const E_IMAGE_TRUNCATED: u16 = 6015;
// AACS (7xxx)
pub const E_AACS_NO_KEYS: u16 = 7000;
@@ -461,6 +462,14 @@ pub enum Error {
MapfileInvalid {
kind: &'static str,
},
/// The image a mapfile describes is shorter than the mapfile's own total
/// size, so the two no longer agree about the same disc. Resuming against
/// it would treat the absent tail as already recovered. `have` and `want`
/// are byte lengths; a missing file reports `have == 0`.
ImageTruncated {
have: u64,
want: u64,
},
// AACS (7xxx)
AacsNoKeys,
@@ -859,6 +868,7 @@ impl Error {
Error::NoStreams => E_NO_STREAMS,
Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
Error::ImageTruncated { .. } => E_IMAGE_TRUNCATED,
Error::AacsNoKeys => E_AACS_NO_KEYS,
Error::AacsCertShort => E_AACS_CERT_SHORT,
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
@@ -1634,6 +1644,13 @@ mod tests {
E_PLATFORM_NOT_IMPLEMENTED,
),
(Error::MapfileInvalid { kind: "hex" }, E_MAPFILE_INVALID),
(
Error::ImageTruncated {
have: 0,
want: 1024,
},
E_IMAGE_TRUNCATED,
),
(Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
// Both CSS no-key verdicts: numeric-only Display, no English.
@@ -1820,6 +1837,7 @@ mod tests {
E_NO_STREAMS,
E_HALTED,
E_MAPFILE_INVALID,
E_IMAGE_TRUNCATED,
E_UDF_BUFFER_TOO_SMALL,
E_UDF_NOT_FILESYSTEM,
E_AACS_NO_KEYS,
@@ -1933,6 +1951,7 @@ mod tests {
assert!((6000..7000).contains(&E_DISC_READ));
assert!((6000..7000).contains(&E_HALTED));
assert!((6000..7000).contains(&E_MAPFILE_INVALID));
assert!((6000..7000).contains(&E_IMAGE_TRUNCATED));
// AACS (7xxx)
assert!((7000..8000).contains(&E_AACS_NO_KEYS));
assert!((7000..8000).contains(&E_NO_DISC_KEY));
+128
View File
@@ -2639,4 +2639,132 @@ mod tests {
assert_eq!(map[&1], vec![(7u16, 1u8), (13, 2)]);
assert_eq!(map[&2], vec![(0x0102u16, 1u8)]);
}
// ── TT_SRPT: the title table (`parse_tt_srpt` / `parse_vmg`) ────────────
//
// This is the table that says which titles a DVD has, and it had no tests
// at all until 1.6.2 — while being at the centre of both DVD stories in
// 1.6.1. The CSS descramble bug destroyed the sector carrying it, so a disc
// enumerating 38 titles produced an image enumerating 10, silently, at exit
// 0. And `dir://` was pulled from the release on 2026-08-05 because a
// folder produced the wrong title list — the same table, reached through a
// synthesized image.
//
// Both were found on real discs. Nothing hermetic covered the table itself.
/// Build a VMG with a TT_SRPT at `tt_srpt_sector` declaring `entries`
/// of `(num_chapters, vts_number, vts_title_num)`.
fn vmg_with_tt_srpt(tt_srpt_sector: u32, entries: &[(u16, u8, u8)]) -> Vec<u8> {
let off = tt_srpt_sector as usize * crate::consts::SECTOR_BYTES;
let mut v = vec![0u8; off + 8 + entries.len() * 12 + 16];
v[0..12].copy_from_slice(b"DVDVIDEO-VMG");
v[0xC4..0xC8].copy_from_slice(&tt_srpt_sector.to_be_bytes());
v[off..off + 2].copy_from_slice(&(entries.len() as u16).to_be_bytes());
for (i, &(chapters, vts, title)) in entries.iter().enumerate() {
let b = off + 8 + i * 12;
v[b + 2..b + 4].copy_from_slice(&chapters.to_be_bytes());
v[b + 6] = vts;
v[b + 7] = title;
}
v
}
#[test]
fn tt_srpt_groups_titles_by_their_title_set() {
// Three titles: two in VTS 1, one in VTS 2 — the ordinary shape.
let vmg = vmg_with_tt_srpt(1, &[(12, 1, 1), (5, 1, 2), (20, 2, 1)]);
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
assert_eq!(map.len(), 2, "two title sets");
assert_eq!(
map[&1],
vec![(12, 1), (5, 2)],
"VTS 1 keeps both titles in order"
);
assert_eq!(map[&2], vec![(20, 1)], "VTS 2 keeps its one");
}
/// The pointer at 0xC4 is a SECTOR offset from the start of VIDEO_TS.IFO,
/// not a byte offset. Reading it as bytes would find the table at 1/2048th
/// of its real position — which is a zero-filled region on most discs, so
/// it would report a disc with no titles rather than fail loudly.
#[test]
fn tt_srpt_pointer_is_a_sector_offset_not_a_byte_offset() {
for sector in [1u32, 2, 5] {
let vmg = vmg_with_tt_srpt(sector, &[(3, 1, 1)]);
let at_sector = parse_tt_srpt(&vmg, sector as usize * crate::consts::SECTOR_BYTES)
.expect("reading at the sector offset works");
assert_eq!(at_sector[&1], vec![(3, 1)], "sector {sector}");
// Reading the same pointer as a BYTE offset lands 1/2048th of the
// way in — inside the header here — and yields a different table.
// It does not error: it silently reports the wrong titles, which is
// how 28 dropped titles looked like a scan difference rather than a
// failure.
let as_bytes = parse_tt_srpt(&vmg, sector as usize).expect("does not error");
assert_ne!(
as_bytes, at_sector,
"sector {sector}: a byte-offset read must not coincidentally \
agree with the correct one, or this test proves nothing"
);
}
}
/// A VTS number of 0 is not a title set; entries carrying it are padding.
#[test]
fn tt_srpt_skips_entries_with_no_title_set() {
let vmg = vmg_with_tt_srpt(1, &[(9, 0, 1), (4, 3, 1)]);
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
assert_eq!(map.len(), 1, "the vts=0 entry is dropped");
assert_eq!(map[&3], vec![(4, 1)]);
}
/// The same (title set, title) twice is one title, not two. Real discs
/// carry duplicate TT_SRPT rows.
#[test]
fn tt_srpt_collapses_a_duplicate_title() {
let vmg = vmg_with_tt_srpt(1, &[(7, 2, 1), (7, 2, 1), (8, 2, 2)]);
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
assert_eq!(
map[&2],
vec![(7, 1), (8, 2)],
"the repeat is dropped, the distinct one kept"
);
}
/// A truncated table parses what is there rather than failing the whole
/// disc — one unreadable row should not cost a user every other title.
#[test]
fn tt_srpt_truncated_keeps_the_entries_it_can_read() {
let mut vmg = vmg_with_tt_srpt(1, &[(1, 1, 1), (2, 2, 1), (3, 3, 1)]);
vmg.truncate(crate::consts::SECTOR_BYTES + 8 + 12 + 6); // 1 whole entry + a fragment
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
assert_eq!(map.len(), 1, "the complete entry survives");
assert_eq!(map[&1], vec![(1, 1)]);
}
/// The count is an untrusted u16 off the disc. DVD-Video caps a disc at 99
/// titles, and each entry re-parses a PGC into a full DvdTitle — so an
/// unclamped 65535 is a ~540 MB allocation from a ~800 KB crafted file.
#[test]
fn tt_srpt_clamps_an_absurd_declared_title_count() {
// The fixture must actually CONTAIN more entries than the cap, or the
// walk stops when the buffer runs out and the clamp is never what
// bounded it. An earlier version of this test declared u16::MAX over a
// two-kilobyte fixture: the loop broke on `base + 12 > len` after three
// iterations and the assertion held with the clamp deleted entirely.
const PRESENT: usize = MAX_TT_SRPT_TITLES + 40;
let entries: Vec<(u16, u8, u8)> = (0..PRESENT)
.map(|i| (1u16, 1u8, (i % 250 + 1) as u8))
.collect();
let mut vmg = vmg_with_tt_srpt(1, &entries);
let off = crate::consts::SECTOR_BYTES;
vmg[off..off + 2].copy_from_slice(&u16::MAX.to_be_bytes());
let map = parse_tt_srpt(&vmg, off).expect("parse");
let total: usize = map.values().map(|v| v.len()).sum();
assert!(
total <= MAX_TT_SRPT_TITLES,
"a declared count of u16::MAX over {PRESENT} real entries must be \
clamped to {MAX_TT_SRPT_TITLES}, got {total}"
);
}
}
+29 -14
View File
@@ -325,20 +325,21 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
///
/// The thread is named `freemkv-pipeline-consumer` so it shows up
/// distinctly in stack traces and `top -H`. Callers that want a
/// more specific name (e.g. `freemkv-sweep-consumer`) should use
/// [`Pipeline::spawn_named`] instead. Returns an `Error::IoError`
/// if the OS refuses the thread spawn (resource exhaustion);
/// callers already operate in fallible context, so this is
/// propagated rather than panicked.
/// more specific name should use [`Pipeline::spawn_named`] instead.
/// Returns an `Error::IoError` if the OS refuses the thread spawn
/// (resource exhaustion); callers already operate in fallible context, so
/// this is propagated rather than panicked.
///
/// Sweep uses [`Pipeline::spawn_named`] directly so the consumer
/// thread shows up as `freemkv-sweep-consumer`; mux uses
/// `freemkv-mux-consumer`. `Pipeline::spawn` (this function, with
/// the default name) is used only by the unit tests in this module.
/// It used to name `disc::patch` as a caller; that module does not exist
/// here any more — the sweep/patch recovery passes moved to freemkv-engine
/// in 1.6.0, so the comment sent readers hunting a caller in this crate
/// that had already left it.
/// Inside this crate the only [`Pipeline::spawn_named`] caller is the mux
/// driver, which names its thread `freemkv-mux-consumer`. `Pipeline::spawn`
/// (this function, with the default name) is used only by the unit tests in
/// this module.
///
/// This paragraph twice named a caller that had left the crate: first
/// `disc::patch`, then Sweep and its `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.
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
}
@@ -346,7 +347,7 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
/// Like [`Pipeline::spawn`] but lets the caller supply the
/// consumer thread's name. Useful when several pipelines run in
/// the same process and stack traces / `top -H` need to tell them
/// apart (e.g. `freemkv-sweep-consumer`, `freemkv-mux-consumer`).
/// apart (e.g. `freemkv-mux-consumer`).
pub fn spawn_named<S: Sink<I, Output = R>>(
name: &str,
depth: usize,
@@ -631,6 +632,14 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
/// wedged inside an unkillable syscall, the producer can still
/// observe `/api/stop` and unwind within
/// [`SEND_HALT_CHECK_INTERVAL`].
/// NOT a `foo_with_X` variant of [`Pipeline::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
/// this one 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.
pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> {
use crossbeam_channel::SendTimeoutError;
let end = Instant::now() + deadline;
@@ -742,6 +751,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
/// Plain [`Pipeline::finish`] is preserved for callers without a
/// halt-token plumbed through; that path still blocks indefinitely
/// on `join()`, matching pre-0.20.8 behaviour.
/// Also not a `foo_with_X` variant: [`Pipeline::finish`] joins and waits
/// however long the consumer needs, while this one 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 this; the unit tests do not and use the plain join. Merging
/// them means picking one of those policies for both.
pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result<R, Error> {
let Pipeline {
tx,
+84 -1
View File
@@ -67,6 +67,13 @@ pub struct Ac3Parser {
/// began in an earlier packet takes THAT packet's source offset, not the
/// one that happened to complete it.
acc: super::pesbuf::PesBuf,
/// Reused working copy of `acc`, kept across calls so the per-PES scan does
/// not allocate. `parse` runs once per PES packet on an audio track — of
/// the order of 10^5 times per title — and previously built a fresh `Vec`
/// each time. The copy itself is unavoidable without restructuring the
/// borrow relationship (the scanner needs `&mut self.tally` while it reads
/// these bytes); the ALLOCATION is not.
scratch: Vec<u8>,
/// PTS (ns) to stamp on the frame that begins the carry-over `buf` — i.e.
/// the running per-frame PTS at the point the partial tail was retained.
/// Used by `flush()` to time the final buffered frame at EOS.
@@ -95,6 +102,7 @@ impl Default for Ac3Parser {
impl Ac3Parser {
pub fn new() -> Self {
Self {
scratch: Vec::new(),
acc: super::pesbuf::PesBuf::with_capacity(4096),
flush_pts_ns: 0,
tally: super::dropgate::DropTally::new("ac3"),
@@ -500,7 +508,12 @@ impl CodecParser for Ac3Parser {
// Copy the working bytes out so the scanner can borrow `self.tally`;
// the buffer keeps its marks, so the unconsumed tail stays attributed
// to the packet that carried it.
let buf = self.acc.as_slice().to_vec();
// Take the scratch OUT of `self` so the scanner can borrow `self.tally`
// while reading it; it is put back at the end of the call, keeping its
// capacity for the next PES. There is no early return after this point.
let mut buf = std::mem::take(&mut self.scratch);
buf.clear();
buf.extend_from_slice(self.acc.as_slice());
let marks = self.acc.marks_snapshot();
let data = &buf;
let (frames, keep_from, frame_pts_ns) =
@@ -541,6 +554,9 @@ impl CodecParser for Ac3Parser {
self.flush_pts_ns = frame_pts_ns;
}
// Hand the working buffer back so the next PES reuses its capacity.
// `data` borrowed it; that borrow ends here, at its last use.
self.scratch = buf;
frames
}
@@ -807,6 +823,73 @@ mod tests {
frame[n - 1] = (c & 0xFF) as u8;
}
/// The per-PES working buffer must be REUSED, 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.
#[test]
fn the_working_buffer_is_reused_across_packets_not_reallocated() {
let mut parser = Ac3Parser::new();
let frame = make_ac3_frame(0, 0);
parser.parse(&PesPacket {
source: None,
pid: 0x1100,
pts: Some(90_000),
dts: None,
data: frame.clone(),
discontinuity: false,
});
let cap_after_first = parser.scratch.capacity();
assert!(
cap_after_first > 0,
"the buffer must be handed back to the parser, not dropped"
);
// The discriminator: a LARGE packet, then a small one. A reused buffer
// keeps the large capacity; a fresh `Vec` sized to each packet drops
// back to the small one. Feeding equal-sized packets cannot tell those
// apart — the first version of this test did exactly that and a
// to_vec()-per-call implementation passed it.
let big = [frame.clone(), frame.clone(), frame.clone(), frame.clone()].concat();
parser.parse(&PesPacket {
source: None,
pid: 0x1100,
pts: None,
dts: None,
data: big.clone(),
discontinuity: false,
});
let cap_after_big = parser.scratch.capacity();
assert!(
cap_after_big >= big.len(),
"the buffer must have grown to hold the large packet"
);
parser.parse(&PesPacket {
source: None,
pid: 0x1100,
pts: None,
dts: None,
data: frame.clone(),
discontinuity: false,
});
assert_eq!(
parser.scratch.capacity(),
cap_after_big,
"after a small packet the buffer must STILL hold the large \
capacity — a fresh allocation per call would have shrunk to the \
small packet's size"
);
}
#[test]
fn parse_empty_pes() {
let mut parser = Ac3Parser::new();
+54 -1
View File
@@ -50,7 +50,15 @@ fn adts_verdict(data: &[u8]) -> AdtsVerdict {
// aac_frame_length: 13 bits = byte3[1:0] | byte4 | byte5[7:5].
let frame_length =
((u32::from(data[3]) & 0x03) << 11) | (u32::from(data[4]) << 3) | (u32::from(data[5]) >> 5);
if frame_length < 7 {
// The floor is the header the frame SAYS it carries, not a constant.
// protection_absent (byte1 bit0) clear means a 16-bit crc_check follows the
// 7-byte fixed+variable header, so the frame cannot be shorter than 9 —
// aac_frame_length counts the header and the CRC, not just the payload.
// Comparing against a flat 7 let a CRC-present frame declaring 7 or 8
// through as structurally Valid, and the muxer then carried a frame whose
// own header says it is impossible.
let header_bytes = if data[1] & 0x01 == 0 { 9 } else { 7 };
if frame_length < header_bytes {
return AdtsVerdict::Invalid;
}
AdtsVerdict::Valid
@@ -152,6 +160,51 @@ mod tests {
}
}
/// A header that CLAIMS a CRC (protection_absent = 0) but declares a
/// frame length too short to contain one.
///
/// `aac_frame_length` counts the header and the CRC, not just the payload,
/// so with a CRC present the smallest structurally possible frame is 9
/// bytes: the 7-byte fixed+variable header plus the 16-bit crc_check.
/// The gate compared against a flat 7 and never read protection_absent at
/// all, so a frame whose own header says it is impossible was classified
/// Valid and forwarded to the muxer.
#[test]
fn a_crc_present_header_shorter_than_its_own_crc_is_invalid() {
for declared in [7u32, 8] {
let mut f = adts_frame(16);
f[1] = 0xF0; // sync + MPEG-4, protection_absent = 0 => CRC present
f[3] = (f[3] & 0xFC) | ((declared >> 11) & 0x03) as u8;
f[4] = ((declared >> 3) & 0xFF) as u8;
f[5] = (f[5] & 0x1F) | ((declared & 0x07) << 5) as u8;
assert!(
matches!(adts_verdict(&f), AdtsVerdict::Invalid),
"protection_absent=0 declaring {declared} bytes cannot hold its \
own 7-byte header plus a 2-byte CRC"
);
}
// 9 is the smallest length that CAN hold header + CRC, so it must pass
// the structural gate — the floor moved, it did not become stricter
// than the spec.
let mut ok = adts_frame(16);
ok[1] = 0xF0;
let nine = 9u32;
ok[3] = (ok[3] & 0xFC) | ((nine >> 11) & 0x03) as u8;
ok[4] = ((nine >> 3) & 0xFF) as u8;
ok[5] = (ok[5] & 0x1F) | ((nine & 0x07) << 5) as u8;
assert!(matches!(adts_verdict(&ok), AdtsVerdict::Valid));
// And with NO CRC the floor is still 7, unchanged.
let mut no_crc = adts_frame(16);
no_crc[1] = 0xF1; // protection_absent = 1
let seven = 7u32;
no_crc[3] = (no_crc[3] & 0xFC) | ((seven >> 11) & 0x03) as u8;
no_crc[4] = ((seven >> 3) & 0xFF) as u8;
no_crc[5] = (no_crc[5] & 0x1F) | ((seven & 0x07) << 5) as u8;
assert!(matches!(adts_verdict(&no_crc), AdtsVerdict::Valid));
}
/// A valid ADTS header (AAC-LC, 44.1 kHz, stereo) + payload, with
/// aac_frame_length set to the total size.
fn adts_frame(payload: usize) -> Vec<u8> {
+18
View File
@@ -717,4 +717,22 @@ mod tests {
let result = format_palette(&[[0x00, 16, 128, 128]], 0, 0);
assert_eq!(String::from_utf8(result).unwrap(), "palette: 101010\n");
}
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
/// cannot see a parser that writes `source: facts.source` where the facts
/// carry no offset. Only a runtime check proves an emitted frame really
/// carries the byte it was read from, and without it a multi-clip title
/// places this track by timestamp inference instead of by byte.
#[test]
fn an_emitted_frame_carries_the_packets_source_offset() {
let mut parser = DvdSubParser::new(None);
let mut pes = make_pes(
vec![0x00, 0x0A, 0x00, 0x08, 0x01, 0xFF, 0x02, 0x03, 0x04, 0x05],
Some(90_000),
);
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
let frames = parser.parse(&pes);
assert!(!frames.is_empty(), "the segment is emitted");
assert_eq!(frames[0].source.map(|s| s.byte), Some(7_777));
}
}
+15
View File
@@ -271,4 +271,19 @@ mod tests {
);
assert_eq!(emitted.len() + tail.len(), 2);
}
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
/// cannot see a parser that writes `source: facts.source` where the facts
/// carry no offset. Only a runtime check proves an emitted frame really
/// carries the byte it was read from, and without it a multi-clip title
/// places this track by timestamp inference instead of by byte.
#[test]
fn an_emitted_frame_carries_the_packets_source_offset() {
let mut p = FlacParser::new();
let mut pes = make_pes(make_flac_frame(100), Some(90_000));
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
let f = p.parse(&pes);
assert!(!f.is_empty(), "the frame is emitted");
assert_eq!(f[0].source.map(|s| s.byte), Some(7_777));
}
}
+17
View File
@@ -279,4 +279,21 @@ mod tests {
let f = parser.parse(&make_pes(vec![0xAA, 0xBB], None));
assert_eq!(f[0].pts_ns, 0);
}
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
/// cannot see a parser that writes `source: facts.source` where the facts
/// carry no offset. Only a runtime check proves an emitted frame really
/// carries the byte it was read from, and without it a multi-clip title
/// places this track by timestamp inference instead of by byte.
#[test]
fn an_emitted_frame_carries_the_packets_source_offset() {
let mut parser = LpcmParser::new();
let mut data = vec![0x00, 0x01, 0x00, 0b1001_0001];
data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE]);
let mut pes = make_pes(data, Some(90_000));
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
let frames = parser.parse(&pes);
assert!(!frames.is_empty(), "the frame is emitted");
assert_eq!(frames[0].source.map(|s| s.byte), Some(7_777));
}
}
+16 -2
View File
@@ -465,9 +465,23 @@ mod provenance_guard {
// `PesFrame` in the tests of other modules is not ours; only
// codec `Frame` literals are scanned, and a test fixture that
// builds a PesPacket with `source: None` is legitimate.
if blk.contains("source: None") {
// Two spellings, not one. `source: None` is the obvious way to
// lose provenance; OMITTING the field entirely is the quiet
// one, because `Frame` derives Default, so
// `Frame { pts_ns, .. Default::default() }` compiles and
// yields `source: None` while containing no such text. A guard
// that only knew the first spelling would have watched a
// parser be rewritten into the second and stayed green.
let explicit_none = blk.contains("source: None");
let no_source_field = !blk.contains("source:");
if explicit_none || no_source_field {
let line = src[..src.find(blk).unwrap_or(0)].lines().count() + 1;
offenders.push(format!("{name}:{line}"));
let how = if explicit_none {
"source: None"
} else {
"no source field (Default fills in None)"
};
offenders.push(format!("{name}:{line} ({how})"));
}
}
}
+15
View File
@@ -319,4 +319,19 @@ mod tests {
// a manufactured tail frame would break this even if it were non-empty.
assert_eq!(emitted.len() + tail.len(), 2);
}
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
/// cannot see a parser that writes `source: facts.source` where the facts
/// carry no offset. Only a runtime check proves an emitted frame really
/// carries the byte it was read from, and without it a multi-clip title
/// places this track by timestamp inference instead of by byte.
#[test]
fn an_emitted_frame_carries_the_packets_source_offset() {
let mut p = MpegAudioParser::new();
let mut pes = make_pes(mp3_frame(400), Some(90_000));
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
let f = p.parse(&pes);
assert!(!f.is_empty(), "the frame is emitted");
assert_eq!(f[0].source.map(|s| s.byte), Some(7_777));
}
}
+5 -5
View File
@@ -1543,14 +1543,14 @@ mod tests {
fn timeline_track0_drives_epoch_others_ride() {
let mut tl = TimelineContinuity::new();
// Clip 1: video 0..10s (track 0 drives the epoch).
assert_eq!(tl.adjust(0, true), 0);
assert_eq!(tl.adjust(0, false), 0); // audio rides the same offset
assert_eq!(tl.adjust(10_000_000_000, true), 10_000_000_000);
assert_eq!(tl.adjust(0, true, 0), 0);
assert_eq!(tl.adjust(0, false, 1), 0); // audio rides the same offset
assert_eq!(tl.adjust(10_000_000_000, true, 0), 10_000_000_000);
// Clip 2 seam: video PTS jumps back to ~0 (> 3s back) → new epoch.
let out = tl.adjust(0, true);
let out = tl.adjust(0, true, 0);
assert!(out >= 10_000_000_000, "epoch must advance past prev high");
// Audio in clip 2 (non-epoch) gets the SAME offset (A/V sync preserved).
let a = tl.adjust(0, false);
let a = tl.adjust(0, false, 1);
assert_eq!(a, out);
}
+8
View File
@@ -1368,6 +1368,14 @@ impl<W: Write + Seek> MkvMuxer<W> {
/// 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
/// TEST-ONLY. Every production path writes through [`MkvMuxer::write_frame_at`]
/// and passes the byte offset the frame was read from; the deferred-mux
/// replay of buffered frames was the last one that did not, and it now
/// does. Keeping this compiled out of the library means there is no
/// production API through which a frame can reach the file with its
/// provenance discarded — the placement logic stopped relying on
/// timestamps precisely so that could not happen silently.
#[cfg(test)]
/// a keyframe. `None` for every non-3D frame.
pub fn write_frame(
&mut self,
+9 -1
View File
@@ -512,13 +512,21 @@ impl MkvStream {
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
}
for (f, additional) in pending.buffered.drain(..) {
muxer.write_frame(
// Provenance must survive the replay. These frames were buffered
// before the muxer existed, but each still carries the byte offset
// it was read from — dropping it here sent them down the timestamp
// heuristic instead, the one path the rest of this change set
// exists to stop relying on. They sit at the head of the title, so
// clip 0 is usually the right guess and the damage was bounded;
// "usually right" is exactly what provenance replaced.
muxer.write_frame_at(
f.track,
f.pts,
f.keyframe,
&f.data,
f.duration_ns,
additional.as_deref(),
f.source.map(|s| s.byte),
)?;
}
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
+26
View File
@@ -2180,10 +2180,25 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
// decrypted, or handed to the demux, so the demux sees one gapless our-variant
// stream (no ciphertext to trip a concealed-gap resync). A non-forensic map
// returns the extents unchanged, so the common disc reads exactly as before.
let full_extents = extents.clone();
let extents = match &key_map {
Some(map) => map.read_plan(&extents, unit_align as u32),
None => extents,
};
// The plan and the clips' feed spans must describe the SAME bytes.
//
// A clip's span was measured over the title's full extents at scan time,
// and a frame is placed by the offset it was read from. When a forensic
// segment makes the plan drop alternate-phase units, the mux feeds fewer
// bytes than the spans describe and the two drift apart cumulatively —
// every frame after the first segment looks earlier than it is, and near a
// join it is placed in the wrong clip or dropped. The spans still tile each
// other perfectly, so the trust check cannot see it.
//
// Provenance is only meaningful when the feed matches. When it does not,
// say so and let placement fall back to timestamps, which is what the
// untrusted path exists for.
let feed_matches_spans = extents == full_extents;
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
if let Some(map) = key_map {
@@ -2202,6 +2217,17 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
// correct `0x8x` sub-streams. No-op for non-DVD or an empty probe. Reset the
// unit base afterward so the prefetcher's first batch starts clean.
let mut title = title;
if !feed_matches_spans {
tracing::info!(
target: "freemkv::mux",
planned = extents.len(),
full = full_extents.len(),
"read plan omits units the clip spans include; placing by timestamps"
);
for c in &mut title.clips {
c.feed_span = None;
}
}
crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title);
decrypting.set_unit_base(0);
+417 -26
View File
@@ -341,6 +341,27 @@ impl SeamPlan {
Some(first)
}
/// 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.
fn clip_in_run_for(&self, first: usize, raw_ns: i64) -> usize {
let span = self.clips[first].feed_span;
let mut i = first;
while i < self.clips.len() && self.clips[i].feed_span == span {
let c = self.clips[i];
if raw_ns >= c.in_ns && raw_ns <= c.out_ns {
return i;
}
i += 1;
}
first
}
/// `None` means DROP: the frame lies outside every clip's marks, so the
/// playlist does not include it.
fn place(
@@ -400,6 +421,18 @@ impl SeamPlan {
&& let Some(b) = src_byte
&& let Some(found) = self.clip_at_byte(b)
{
// One FILE can be referenced by several PlayItems — a seamless
// split, a looped segment, multi-angle — and `bluray.rs` counts its
// extents once, so they all share one span. Provenance therefore
// narrows the frame to the RUN, not to a member of it: every frame
// of the file resolves to the same first index.
//
// Only the timestamp can say which reference a frame belongs to,
// and each carries its own marks. Without this, everything past the
// first PlayItem's OUT was judged against marks it was never inside
// and dropped — half the file missing from the rip while the
// timeline still charged for its duration.
let found = self.clip_in_run_for(found, raw_ns);
let c = self.clips[found];
let placed = raw_ns >= c.in_ns && raw_ns <= c.out_ns;
self.cursors[track] = TrackPos {
@@ -649,11 +682,45 @@ 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 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.
epoch_offsets: Vec<(i64, i64)>,
/// 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.
last_raw_ns: Vec<Option<i64>>,
/// 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.
provisional: Vec<Option<(usize, i64)>>,
}
/// Most epochs retained for straggler resolution. A title has a handful; this
/// only bounds a pathological source that rebases without end.
const MAX_EPOCHS: usize = 64;
impl TimelineContinuity {
pub(crate) fn new() -> Self {
Self {
epoch_offsets: Vec::new(),
last_raw_ns: Vec::new(),
provisional: Vec::new(),
offset_ns: 0,
prev_offset_ns: 0,
high_ns: None,
@@ -687,6 +754,9 @@ impl TimelineContinuity {
crate::disc::ContentFormat::MpegPs => None,
};
Self {
epoch_offsets: Vec::new(),
last_raw_ns: Vec::new(),
provisional: Vec::new(),
offset_ns: 0,
prev_offset_ns: 0,
high_ns: None,
@@ -734,7 +804,95 @@ impl TimelineContinuity {
}
return placed;
}
Some(self.adjust(raw_pts_ns, drives_epoch))
Some(self.adjust(raw_pts_ns, drives_epoch, track))
}
/// 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`.
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);
self.provisional.resize(track + 1, None);
}
let prev_raw = self.last_raw_ns[track].replace(raw_pts_ns);
let retired = self.epoch_offsets.len();
// A provisional only survives until the video opens the epoch for real.
if let Some((taken_at, _)) = self.provisional[track]
&& taken_at != retired
{
self.provisional[track] = None;
}
let effective = self.provisional[track].map_or(self.offset_ns, |(_, o)| o);
if let Some(high) = self.high_ns
&& self.provisional[track].is_none()
&& let Some(pr) = prev_raw
&& raw_pts_ns < pr.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
{
let mapped = raw_pts_ns.saturating_add(effective);
if mapped < high.saturating_sub(DISCONTINUITY_BACKSTEP_NS) {
let off = high
.saturating_sub(mapped)
.saturating_add(DISCONTINUITY_GAP_NS);
let off = effective.saturating_add(off);
self.provisional[track] = Some((retired, off));
return off;
}
}
effective
}
/// Retire the current epoch and open a new one continuing just after the
/// frontier.
///
/// 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.
fn open_epoch(&mut self, high: i64, mapped_now: i64) {
self.prev_offset_ns = self.offset_ns;
if self.epoch_offsets.len() == MAX_EPOCHS {
self.epoch_offsets.remove(0);
}
self.epoch_offsets.push((self.offset_ns, high));
let bump = high
.saturating_sub(mapped_now)
.saturating_add(DISCONTINUITY_GAP_NS);
self.offset_ns = self.offset_ns.saturating_add(bump);
}
/// 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.
fn straggler_offset(&self, raw_pts_ns: i64) -> Option<i64> {
self.epoch_offsets
.iter()
.map(|(o, closing)| (raw_pts_ns.saturating_add(*o), *closing))
// In the TAIL of that epoch: at or just below where it ended. A
// frame landing far below its close is not a straggler at all — it
// is a normal frame of a LATER epoch that merely leads a sparse
// frontier, and demoting it would mis-time it by a whole clip.
.filter(|(m, closing)| {
*m <= *closing && *m >= closing.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
})
.map(|(m, _)| m)
.max()
}
/// Map a raw PES PTS (ns) onto the continuous output timeline.
@@ -762,12 +920,13 @@ impl TimelineContinuity {
/// - **Everything else** (normal progression + sub-threshold B-frame reorder
/// dips) passes through with the current offset and advances the frontier,
/// preserving PTS.
pub(crate) fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool) -> i64 {
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
// (sparse/laggy/independent) timeline and would false-trigger the ratchet.
if !drives_epoch {
let mapped = raw_pts_ns.saturating_add(self.offset_ns);
let effective = self.passive_offset(track, raw_pts_ns);
let mapped = raw_pts_ns.saturating_add(effective);
// Tail-straggler remap: at a REAL (base-video-driven) multi-clip
// boundary the offset has just jumped forward by ~a whole clip, but a
// lagging tail frame from the just-ended clip still carries an
@@ -798,13 +957,9 @@ impl TimelineContinuity {
// that fires the straggler clamp on essentially every passive frame.
if let Some(high) = self.high_ns
&& mapped > high.saturating_add(DISCONTINUITY_BACKSTEP_NS)
&& let Some(placed) = self.straggler_offset(raw_pts_ns)
{
let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns);
if prev_mapped <= high
&& prev_mapped >= high.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
{
return prev_mapped;
}
return placed;
}
return mapped;
}
@@ -819,14 +974,10 @@ impl TimelineContinuity {
// Clip-boundary reset (real multi-clip seam): continue just after the
// frontier. Save the previous offset so a lagging non-video tail
// frame can be recognised and remapped to the seam (see above).
self.prev_offset_ns = self.offset_ns;
// `high - adj` is a backward step, so positive — but both ends are
// untrusted (`high` up to i64::MAX, `adj` down to i64::MIN), so
// saturate rather than panic in a checked build.
let bump = high
.saturating_sub(adj)
.saturating_add(DISCONTINUITY_GAP_NS);
self.offset_ns = self.offset_ns.saturating_add(bump);
// `open_epoch` saturates rather than panic in a checked build.
self.open_epoch(high, adj);
let adj2 = raw_pts_ns.saturating_add(self.offset_ns);
self.high_ns = Some(high.max(adj2));
adj2
@@ -847,10 +998,10 @@ mod tests {
// Convenience: a video frame drives epoch decisions; non-video rides the
// current offset. These wrappers make the test intent explicit.
fn adj_video(tc: &mut TimelineContinuity, p: i64) -> i64 {
tc.adjust(p, true)
tc.adjust(p, true, 0)
}
fn adj_other(tc: &mut TimelineContinuity, p: i64) -> i64 {
tc.adjust(p, false)
tc.adjust(p, false, 1)
}
/// Characterization of the BUG: a BD title's two clips concatenated with a
@@ -1044,6 +1195,21 @@ mod tests {
/// Clips with byte spans, built from the real mark table so provenance and
/// marks can be tested against each other.
/// The output offset `from_clips` computes for a clip: the sum of every
/// earlier clip's playable duration, minus its own IN.
fn plan_offset_for(clips: &[crate::disc::Clip], want: &crate::disc::Clip) -> i64 {
let mut cum = 0i64;
for c in clips {
let in_ns = mpls_ticks_to_ns(c.in_time);
let out_ns = mpls_ticks_to_ns(c.out_time);
if c.clip_id == want.clip_id && c.in_time == want.in_time {
return cum;
}
cum += out_ns - in_ns;
}
cum
}
fn clips_with_spans() -> Vec<crate::disc::Clip> {
let mut clips = seamless_branching_clips();
// Each clip's stream occupies a contiguous run of the feed. Sizes are
@@ -1144,6 +1310,50 @@ mod tests {
/// 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.
#[test]
fn a_clip_split_across_two_play_items_keeps_both_halves() {
// One file, one span. Two PlayItems: [0s,10s) then [10s,20s).
const SPAN: (u64, u64) = (0, 4_000_000);
let mk = |in_t: u32, out_t: u32| crate::disc::Clip {
feed_span: Some(SPAN),
clip_id: "00001".into(),
in_time: in_t,
out_time: out_t,
duration_secs: 10.0,
source_packets: 0,
};
// 45 kHz ticks: 10 s = 450_000, 20 s = 900_000.
let clips = vec![mk(0, 450_000), mk(450_000, 900_000)];
let mut plan = SeamPlan::from_clips(&clips).expect("two PlayItems, one file");
assert!(plan.spans_trusted, "one file legitimately has one span");
// A frame from the SECOND PlayItem's range, read from within the file.
let raw = mpls_ticks_to_ns(600_000); // 13.33 s — inside [10s, 20s)
let placed = plan.place(raw, 0, false, Some(2_000_000));
assert!(
placed.is_some(),
"a frame in the second PlayItem's marks must be kept — it resolves \
to the same span as the first, so only its timestamp can say which \
half of the file it belongs to"
);
assert_eq!(
plan.dropped_total(),
0,
"nothing from a legitimately referenced half may be dropped"
);
}
#[test]
fn a_repeated_clip_shares_one_span_and_is_still_trusted() {
let mut clips = clips_with_spans();
@@ -1171,9 +1381,34 @@ mod tests {
fn no_provenance_still_places_by_marks() {
let clips = clips_with_spans();
let mut plan = SeamPlan::from_clips(&clips).expect("plan");
// `is_some()` alone was the whole assertion here, which passes for a
// frame placed in the WRONG clip — on the one path taken whenever a
// source stamps no offset.
//
// This timestamp sits in the OVERLAP of two clips in the real mark
// table, which is precisely the ambiguity provenance exists to settle,
// so pinning one specific clip would be asserting the coin-flip. The
// invariant that holds either way: the frame is placed with the offset
// of a clip whose marks actually contain it — never a clip it does not
// belong to, and never at the head of the timeline.
let raw = 7_900_000_000_000i64;
let placed = plan
.place(raw, 0, true, None)
.expect("a frame with no source offset must still be placed");
let candidates: Vec<i64> = clips
.iter()
.filter(|c| raw >= mpls_ticks_to_ns(c.in_time) && raw <= mpls_ticks_to_ns(c.out_time))
.map(|c| raw - mpls_ticks_to_ns(c.in_time) + plan_offset_for(&clips, c))
.collect();
assert!(
plan.place(7_900_000_000_000, 0, true, None).is_some(),
"a frame with no source offset must still be placed"
!candidates.is_empty(),
"fixture check: the probe timestamp must sit inside at least one clip"
);
assert!(
candidates.contains(&placed),
"placed at {placed}, but no clip containing this timestamp maps it there \
(candidates {candidates:?}) — a frame was given a clip it does not belong to"
);
}
@@ -1353,13 +1588,13 @@ mod tests {
feed_span: Some(SPAN),
})
.collect();
let plan = SeamPlan::from_clips(&clips).expect("plan");
let mut plan = SeamPlan::from_clips(&clips).expect("plan");
assert!(
plan.spans_trusted,
"identical spans pass the tiling check -- this is the hazard"
"identical spans pass the tiling check -- one file has one span"
);
// Every byte in the file resolves to the FIRST play item, so a byte
// offset cannot say which of the 8 ranges a frame belongs to.
// A byte offset alone cannot say which of the 8 ranges a frame belongs
// to: every byte in the file resolves to the FIRST play item.
for b in [0u64, 1_000_000, 20_000_000_000, 39_999_999_999] {
assert_eq!(
plan.clip_at_byte(b),
@@ -1367,6 +1602,23 @@ mod tests {
"byte {b} resolves to the first play item, always"
);
}
// ...which is why placement must finish the job with the timestamp.
// This used to drop 7/8 of the title: every frame past the first
// range's OUT was judged against marks it was never inside. Each
// range's own frame must now be kept, and land in ITS range.
for i in 0..N {
let mid = mpls_ticks_to_ns(i * SEG + SEG / 2);
let placed = plan
.place(mid, 0, false, Some(1_000_000))
.unwrap_or_else(|| panic!("a frame in play item {i}'s marks must be kept"));
assert!(placed >= 0, "play item {i} placed at {placed}");
}
assert_eq!(
plan.dropped_total(),
0,
"no range of a legitimately split file may be dropped"
);
}
/// Marks that do not advance across the title are normal — each clip file
@@ -2206,9 +2458,9 @@ mod tests {
// A NORMAL clip-2 audio frame at raw ~5s. Current-offset mapping is
// ~605s, which IS more than a backstep (3s) past the 600s frontier — but
// its previous-offset mapping (~5s) lands ~595s BELOW the frontier, far
// outside the seam tail. It is a legitimate new-epoch frame, NOT a tail
// straggler, and must ride the current offset.
// it lands ~595s BELOW the end of the epoch it would be demoted into,
// far outside that epoch's tail. It is a legitimate new-epoch frame, NOT
// a straggler, and must ride the current offset.
let raw = 5 * S;
let out = adj_other(&mut tc, raw);
assert_eq!(
@@ -2224,6 +2476,145 @@ mod tests {
);
}
/// 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.
#[test]
fn frames_arriving_before_their_epochs_video_ride_a_provisional_offset() {
let mut tc = TimelineContinuity::new();
for i in 0..=600 {
adj_video(&mut tc, i * S);
}
let frontier = tc.high_ns.expect("frontier");
adj_other(&mut tc, 599 * S);
// The next cell's audio arrives first, raw restarted at 0.
let step = 32_000_000; // 32 ms, the measured cadence
let a0 = adj_other(&mut tc, 0);
let a1 = adj_other(&mut tc, step);
let a2 = adj_other(&mut tc, 2 * step);
assert_eq!(
a0,
frontier + DISCONTINUITY_GAP_NS,
"an early frame must continue after the frontier, not land an epoch \
in the past where the monotonic nudge crushes it"
);
assert_eq!(a1 - a0, step, "the run keeps its cadence");
assert_eq!(a2 - a1, step);
// Critically: the video timeline must be untouched. Letting a passive
// track open a real epoch inflated a 476.776 s title to 656.216 s.
assert_eq!(tc.offset_ns, 0, "a provisional must not move the offset");
assert_eq!(tc.high_ns, Some(frontier), "nor the frontier");
assert!(tc.epoch_offsets.is_empty(), "nor retire an epoch");
// Now the video for that cell arrives and opens the epoch for real.
let v = adj_video(&mut tc, 0);
assert_eq!(v, frontier + DISCONTINUITY_GAP_NS);
assert_eq!(
tc.epoch_offsets.len(),
1,
"exactly one epoch, opened by video"
);
// The next audio frame rejoins the real offset with no seam: both are
// `frontier - mapping + gap`, so they agree.
let a3 = adj_other(&mut tc, 3 * step);
assert_eq!(a3 - a2, step, "the run continues across the handover");
}
/// A provisional must not outlive its epoch: once the video has retired an
/// epoch, later frames of that track take the real offset. Otherwise a track
/// would drift away from every other one for the rest of the title.
#[test]
fn a_provisional_is_dropped_once_the_epoch_is_real() {
let mut tc = TimelineContinuity::new();
for i in 0..=600 {
adj_video(&mut tc, i * S);
}
adj_other(&mut tc, 599 * S);
adj_other(&mut tc, 0); // takes a provisional
assert!(tc.provisional[1].is_some());
adj_video(&mut tc, 0); // video opens the epoch for real
adj_other(&mut tc, S); // next frame of that track
assert!(
tc.provisional[1].is_none(),
"the provisional must be dropped once the real epoch exists"
);
assert_eq!(
adj_other(&mut tc, 2 * S),
2 * S + tc.offset_ns,
"the track now rides the real offset like every other"
);
}
/// 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.
#[test]
fn a_straggler_is_judged_against_its_own_epochs_end_not_the_frontier() {
let mut tc = TimelineContinuity::new();
for i in 0..=600 {
adj_video(&mut tc, i * S);
}
adj_video(&mut tc, 0); // clip 2 opens; epoch 0 closed at 600 s
for i in 0..=23 {
adj_video(&mut tc, i * S);
}
let frontier = tc.high_ns.expect("frontier");
// A clip-1 tail frame, 0.2 s before that clip ended, arriving late.
let raw = 599 * S + 800_000_000;
let out = adj_other(&mut tc, raw);
assert_eq!(
out, raw,
"a tail straggler belongs to the epoch its raw PTS came from"
);
assert!(
out < frontier,
"it must never be flung past the frontier, got {out} vs {frontier}"
);
}
/// Why the epoch HISTORY is needed and one `prev_offset_ns` is not enough:
/// with three epochs, a straggler from the FIRST cannot be named by the
/// immediately-previous offset at all.
#[test]
fn a_straggler_from_an_epoch_before_last_is_still_placed() {
let mut tc = TimelineContinuity::new();
for i in 0..=600 {
adj_video(&mut tc, i * S);
}
adj_video(&mut tc, 0); // epoch 0 closes at 600 s
for i in 0..=300 {
adj_video(&mut tc, i * S);
}
adj_video(&mut tc, 0); // epoch 1 closes at ~900 s
for i in 0..=50 {
adj_video(&mut tc, i * S);
}
// Tail frame of epoch 0 — two epochs back.
let raw = 599 * S + 500_000_000;
assert_eq!(
adj_other(&mut tc, raw),
raw,
"the straggler belongs to epoch 0, which `prev_offset_ns` no longer names"
);
}
/// 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`.
+1 -1
View File
@@ -450,7 +450,7 @@ const AACS_PROBE_UNITS: usize = 8;
///
/// 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 (`disc/mod.rs:1992-1993`). For the common
/// `/AACS` or `/BDMV/AACS` directory (see `disc::aacs_dir_present`). For the common
/// case — a MakeMKV-style 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