libfreemkv: rc.5.1 DVD correctness fixes

- CSS: unlock scrambled-sector reads on enforcing drives via bus-auth
  only; classify sense 6F/03 as CSS-locked; early-bail on a fully locked
  scan; gate the AACS handshake off DVD discs.
- DVD first-play menu no longer prepended to the feature: read the title
  VOBS base from vtstt_vobs (0xC4), not the menu VOBS vtsm_vobs (0xC0).
- Interlaced field-duration (DefaultDecodedFieldDuration) written as a
  direct TrackEntry child rather than inside Video, so Windows reports
  the correct frame rate.
- Audio channel count read from the AC-3 bitstream; FieldOrder set to
  TFF; per-track BPS tags.
- Structured disc diagnostics at --log-level 3; reduced per-operation
  log spam.
This commit is contained in:
Matthew Jackson
2026-06-24 14:34:55 -07:00
parent 315276dd13
commit 6592f2a590
18 changed files with 1938 additions and 120 deletions
+337 -14
View File
@@ -36,6 +36,9 @@ pub struct MkvTrack {
// meaningful when interlaced; `FIELD_ORDER_UNDETERMINED` omits it.
pub interlaced: bool,
pub field_order: u8,
/// DefaultDecodedFieldDuration (ns per field) for interlaced video — half
/// the frame `default_duration_ns`. 0 = omit (progressive / unknown).
pub field_duration_ns: u64,
// Audio-specific
pub sample_rate: f64,
pub channels: u8,
@@ -131,17 +134,25 @@ impl MkvTrack {
colour_primaries: primaries,
colour_range: range,
interlaced: v.resolution.is_interlaced(),
// PAL DVD (576i) is bottom-field-first; NTSC DVD (480i) is
// top-field-first. HD interlaced (1080i) is top-field-first.
// Progressive content leaves the field order undetermined.
// PAL DVD (576i), NTSC DVD (480i), and HD interlaced (1080i) are
// all top-field-first ("almost everything but DV is TFF"). MediaInfo
// reads "Top Field First" off the MPEG-2 picture coding extension,
// so the container element must agree — emitting BFF here for 576i
// (the pre-rc.5.1 value) was a wrong container value that disagreed
// with the stream. Progressive content leaves the order undetermined.
field_order: if v.resolution.is_interlaced() {
match v.resolution {
Resolution::R576i => ebml::FIELD_ORDER_BFF,
_ => ebml::FIELD_ORDER_TFF,
}
ebml::FIELD_ORDER_TFF
} else {
ebml::FIELD_ORDER_UNDETERMINED
},
// One field is half a frame. For 576i 25 fps (40 ms frame) this is
// 20 ms; for 480i 29.97 fps (~33.4 ms frame) ~16.68 ms. Only set on
// interlaced tracks with a known frame duration.
field_duration_ns: if v.resolution.is_interlaced() && default_duration_ns > 0 {
default_duration_ns / 2
} else {
0
},
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -214,6 +225,7 @@ impl MkvTrack {
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: sr,
channels: ch,
bit_depth: 0,
@@ -249,6 +261,7 @@ impl MkvTrack {
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -305,6 +318,33 @@ pub struct MkvMuxer<W: Write + Seek> {
info_offset: u64,
tracks_offset: u64,
chapters_offset: Option<u64>,
/// Total payload bytes muxed PER TRACK (index = track_idx). Used to emit a
/// per-track `BPS` statistics tag (bytes*8/duration) at finalize so Windows
/// shows a bitrate for every track, not just CBR audio.
track_bytes: Vec<u64>,
/// Track UIDs in track order (parallels `track_bytes`), for the BPS Targets.
track_uids: Vec<u64>,
/// Segment duration in seconds (from `Info`), for the BPS denominator.
duration_secs: f64,
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
/// is unreliable, so the channel count written in the track header is
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
/// track. Each entry records the file offset of the 1-byte Channels value
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
/// `corrected` flips once patched so we only act on the first frame.
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
}
/// Deferred AC-3 channel-count correction: the track header's `Channels` byte
/// is written up-front from the (unreliable) IFO count; on the first AC-3 frame
/// for the track the value is rewritten from the bitstream `acmod`.
struct Ac3ChannelFixup {
/// Absolute file offset of the 1-byte Channels value in the Tracks element.
value_offset: u64,
/// Channel count the IFO claimed (already written at `value_offset`).
claimed: u8,
/// True once the first frame has been parsed and the value finalised.
corrected: bool,
}
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
@@ -645,10 +685,15 @@ impl<W: Write + Seek> MkvMuxer<W> {
let tracks_start = writer.stream_position()?;
let tracks_offset = tracks_start - segment_start;
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
std::collections::HashMap::new();
for (i, track) in tracks.iter().enumerate() {
let track_uid = (i + 1) as u64 | 0x100_0000;
track_uids.push(track_uid);
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x100_0000)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
@@ -682,6 +727,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
)?;
}
// DefaultDecodedFieldDuration (one FIELD = half a frame) on
// interlaced tracks. Per the Matroska schema it is a DIRECT child
// of TrackEntry (NOT inside Video). Without it an interlace-aware
// reader (Windows shell) assumes "block = one field" and reports
// half the frame rate (12.5 instead of 25 for 576i). DefaultDuration
// above stays the full-frame period (40 ms); this is 20 ms.
if track.track_type == ebml::TRACK_TYPE_VIDEO
&& track.interlaced
&& track.field_duration_ns > 0
{
ebml::write_uint(
&mut writer,
ebml::DEFAULT_DECODED_FIELD_DURATION,
track.field_duration_ns,
)?;
}
// Video-specific
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?;
@@ -748,7 +810,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Omit Channels when unknown (0) — Matroska defaults it to 1
// rather than us fabricating a 6-channel count.
if track.channels > 0 {
// Record the offset of the 1-byte Channels value so an AC-3
// track can correct it from the bitstream acmod on its first
// frame (the IFO nibble is unreliable). write_uint emits
// ID(0x9F, 1B) + size(0x81, 1B) + value(1B) for 1..=255, so
// the value byte sits 2 bytes after the element start.
let chan_elem_pos = writer.stream_position()?;
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
if track.codec_id == ebml::CODEC_AC3 {
ac3_channel_fixups.insert(
i,
Ac3ChannelFixup {
value_offset: chan_elem_pos + 2,
claimed: track.channels,
corrected: false,
},
);
}
}
if track.bit_depth > 0 {
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?;
@@ -803,6 +881,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
info_offset,
tracks_offset,
chapters_offset,
track_bytes: vec![0u64; tracks.len()],
track_uids,
duration_secs,
ac3_channel_fixups,
})
}
@@ -969,6 +1051,42 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
self.frame_count += 1;
// Per-track byte total for the finalize-time BPS statistics tag.
if let Some(b) = self.track_bytes.get_mut(track_idx) {
*b += data.len() as u64;
}
// Correct the AC-3 track's Channels element from the bitstream acmod on
// the FIRST frame of the track. The DVD IFO audio nibble is unreliable
// (it claims 5.1 on a 2.0 stream); the bitstream acmod is authoritative.
// Only the first frame triggers it; the byte width is unchanged so the
// patch is a single-byte in-place rewrite (then restore position).
if let Some(fixup) = self.ac3_channel_fixups.get_mut(&track_idx) {
if !fixup.corrected {
match super::codec::ac3::acmod_channels(data) {
Some(actual) if actual > 0 => {
if actual != fixup.claimed {
tracing::warn!(
target: "mux",
"AC-3 track {track_idx}: IFO claimed {} channels but bitstream acmod says {}; trusting the bitstream (possible wrong-stream selection)",
fixup.claimed,
actual,
);
let here = self.writer.stream_position()?;
self.writer
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
self.writer.write_all(&[actual])?;
self.writer.seek(std::io::SeekFrom::Start(here))?;
}
fixup.corrected = true;
}
// Frame too short to carry the BSI bits — keep the passed
// (IFO) value and try again on the next frame.
_ => {}
}
}
}
Ok(())
}
@@ -1015,6 +1133,12 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut self.writer, cues_pos)?;
}
// Per-track BPS statistics tags (mkvmerge convention). A reader that
// reads the container `BPS` tag (Windows Explorer's MKV property
// handler) rather than computing bitrate from stream size shows a
// bitrate for EVERY track this way, not just CBR audio.
self.write_bps_tags()?;
// Back-patch SeekHead SeekPosition values now that all element offsets are known.
for fixup in &self.seek_fixups {
let offset = match fixup.target_id {
@@ -1036,6 +1160,48 @@ impl<W: Write + Seek> MkvMuxer<W> {
Ok(())
}
/// Write a `Tags` master with a per-track `BPS` SimpleTag (bytes*8 /
/// duration_secs). Mirrors mkvmerge's per-track statistics tag so readers
/// that surface the container tag (Windows Explorer) show a bitrate for
/// every track. No-op when the duration is unknown (can't compute a rate)
/// or no track carried any bytes.
fn write_bps_tags(&mut self) -> io::Result<()> {
if self.duration_secs <= 0.0 {
return Ok(());
}
if self.track_bytes.iter().all(|&b| b == 0) {
return Ok(());
}
let tags_pos = ebml::start_master(&mut self.writer, ebml::TAGS)?;
// Snapshot to avoid borrowing self across the writer borrow.
let entries: Vec<(u64, u64)> = self
.track_uids
.iter()
.zip(self.track_bytes.iter())
.map(|(&uid, &bytes)| (uid, bytes))
.collect();
for (uid, bytes) in entries {
if bytes == 0 {
continue;
}
// bits per second = bytes * 8 / duration_secs, rounded to nearest.
let bps = ((bytes as f64) * 8.0 / self.duration_secs).round() as u64;
let tag_pos = ebml::start_master(&mut self.writer, ebml::TAG)?;
// Targets → TagTrackUID (this tag applies to one track).
let targets_pos = ebml::start_master(&mut self.writer, ebml::TARGETS)?;
ebml::write_uint(&mut self.writer, ebml::TAG_TRACK_UID, uid)?;
ebml::end_master(&mut self.writer, targets_pos)?;
// SimpleTag(TagName="BPS", TagString="<bps>").
let st_pos = ebml::start_master(&mut self.writer, ebml::SIMPLE_TAG)?;
ebml::write_string(&mut self.writer, ebml::TAG_NAME, "BPS")?;
ebml::write_string(&mut self.writer, ebml::TAG_STRING, &bps.to_string())?;
ebml::end_master(&mut self.writer, st_pos)?;
ebml::end_master(&mut self.writer, tag_pos)?;
}
ebml::end_master(&mut self.writer, tags_pos)?;
Ok(())
}
fn start_cluster(&mut self, ts_ticks: i64) -> io::Result<()> {
// Close previous cluster if open
if self.cluster_open {
@@ -1199,6 +1365,7 @@ mod tests {
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -1226,6 +1393,7 @@ mod tests {
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 48000.0,
channels: 6,
bit_depth: 0,
@@ -2986,12 +3154,12 @@ mod tests {
#[test]
fn video_emits_flag_interlaced_and_field_order() {
// An interlaced (576i PAL) track must emit FlagInterlaced=1 and
// FieldOrder=9 (bottom-field-first). A progressive track must emit
// FlagInterlaced=2 and NO FieldOrder.
// An interlaced track must emit FlagInterlaced=1 and its FieldOrder
// value. A progressive track must emit FlagInterlaced=2 and NO
// FieldOrder.
let mut interlaced = make_video_track();
interlaced.interlaced = true;
interlaced.field_order = ebml::FIELD_ORDER_BFF;
interlaced.field_order = ebml::FIELD_ORDER_TFF;
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[interlaced], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
@@ -3004,8 +3172,8 @@ mod tests {
let fo = find_id(&data, ebml::FIELD_ORDER).expect("FieldOrder present");
assert_eq!(
data[fo + 2],
ebml::FIELD_ORDER_BFF,
"FieldOrder must be 9 (bottom-field-first) for PAL DVD"
ebml::FIELD_ORDER_TFF,
"FieldOrder value must round-trip through the writer"
);
// Progressive track: FlagInterlaced=2, no FieldOrder.
@@ -3030,6 +3198,161 @@ mod tests {
);
}
#[test]
fn video_576i_defaults_to_top_field_first() {
// PAL 576i must default to TFF (2), not BFF — the container element must
// agree with the MPEG-2 stream (MediaInfo reads "Top Field First" off
// the picture coding extension). The pre-rc.5.1 BFF(9) was a wrong value.
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R576i,
frame_rate: crate::disc::FrameRate::F25,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt470bg,
display_aspect: Some((16, 9)),
secondary: false,
label: String::new(),
};
let t = MkvTrack::video(&v);
assert!(t.interlaced, "576i is interlaced");
assert_eq!(
t.field_order,
ebml::FIELD_ORDER_TFF,
"576i must default to top-field-first"
);
}
#[test]
fn interlaced_576i_emits_default_decoded_field_duration() {
// 576i @ 25 fps: DefaultDuration = 40 ms (frame), and
// DefaultDecodedFieldDuration = 20 ms (field = half a frame). The field
// element stops interlace-aware readers (Windows) halving the frame rate.
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R576i,
frame_rate: crate::disc::FrameRate::F25,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt470bg,
display_aspect: None,
secondary: false,
label: String::new(),
};
let t = MkvTrack::video(&v);
assert_eq!(t.default_duration_ns, 40_000_000, "frame duration is 40 ms");
assert_eq!(t.field_duration_ns, 20_000_000, "field duration is 20 ms");
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// DefaultDuration (frame) present and = 40 ms.
let dd = find_id(&data, ebml::DEFAULT_DURATION).expect("DefaultDuration present");
// [id 3B][size 0x84][4-byte value] — 40_000_000 needs 4 bytes.
let frame_ns = u32::from_be_bytes([data[dd + 4], data[dd + 5], data[dd + 6], data[dd + 7]]);
assert_eq!(frame_ns, 40_000_000, "DefaultDuration is the full frame");
// DefaultDecodedFieldDuration present and = 20 ms.
let fd =
find_id(&data, ebml::DEFAULT_DECODED_FIELD_DURATION).expect("field duration present");
let field_ns = u32::from_be_bytes([data[fd + 4], data[fd + 5], data[fd + 6], data[fd + 7]]);
assert_eq!(field_ns, 20_000_000, "field duration is half the frame");
}
#[test]
fn progressive_video_omits_field_duration() {
// A progressive track must NOT carry DefaultDecodedFieldDuration.
let t = make_video_track(); // progressive
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::DEFAULT_DECODED_FIELD_DURATION).is_none(),
"no field duration for progressive content"
);
}
#[test]
fn finalize_emits_per_track_bps_tags() {
// At finalize a Tags master with a per-track BPS SimpleTag is written.
// BPS = bytes*8/duration_secs. With a 10 s duration and a video frame of
// 1000 bytes, video BPS = 1000*8/10 = 800.
let tracks = [make_video_track(), make_audio_track()];
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
// Video keyframe 1000 bytes; audio frame 500 bytes.
muxer
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
.unwrap();
muxer
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// The Tags master must be present as a top-level Segment child.
let children = segment_children(&data);
assert!(
children.iter().any(|(id, _, _)| *id == ebml::TAGS),
"Tags element must be written at finalize"
);
// The BPS values must appear as TagString text. Video: 800, Audio: 400.
let text = String::from_utf8_lossy(&data);
assert!(text.contains("BPS"), "BPS TagName must be present");
assert!(
text.contains("800"),
"video BPS (1000*8/10) must be present"
);
assert!(text.contains("400"), "audio BPS (500*8/10) must be present");
}
#[test]
fn no_bps_tags_when_duration_unknown() {
// With duration 0 (unknown) the BPS rate can't be computed; no Tags.
let tracks = [make_video_track()];
let frames = vec![(0usize, 0i64, true, vec![0xABu8; 1000])];
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let children = segment_children(&data);
assert!(
!children.iter().any(|(id, _, _)| *id == ebml::TAGS),
"no Tags element when duration is unknown"
);
}
#[test]
fn ac3_channels_corrected_from_bitstream_acmod() {
// The audio track header claims 6 channels (IFO 5.1), but the AC-3
// bitstream's first frame has acmod=2 (2.0 stereo). The Channels element
// must be rewritten to 2 from the bitstream, not left at the IFO's 6.
let mut audio = make_audio_track(); // codec A_AC3, channels = 6
audio.channels = 6;
let video = make_video_track();
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &[video, audio], None, 0.0, &[]).unwrap();
// A minimal AC-3 BSI with acmod=2 (2/0 stereo), no LFE → 2 channels.
// byte5 = bsid 8 (legacy AC-3). byte6: acmod(010) | dsurmod(00) |
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
// Open a cluster with a video keyframe first (cluster invariant).
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// Locate the Channels element (0x9F) WITHIN the Tracks body (so a stray
// 0x9F in cluster/AC-3 payload can't be mistaken for the element) and
// assert the value byte is 2.
let (tracks_start, tracks_size) = segment_children(&data)
.into_iter()
.find_map(|(id, off, sz)| (id == ebml::TRACKS).then_some((off, sz as usize)))
.expect("Tracks element present");
let tracks_body = &data[tracks_start..tracks_start + tracks_size];
let ch = find_id(tracks_body, ebml::CHANNELS).expect("Channels element present");
assert_eq!(
tracks_body[ch + 2],
2,
"Channels must be corrected to 2 (bitstream acmod), not 6 (IFO)"
);
}
#[test]
fn dolby_vision_track_emits_block_addition_mapping() {
// A DV track (dv_config set) must emit BlockAdditionMapping (0x41E4)