From 48fb9b2cf6f6a303a352d61178d7532012f9868e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:38:45 -0700 Subject: [PATCH] mpls: locate STN table past the multi-angle angle block (issue #45) The PlayItem STN (stream-number) table offset was hard-coded to 32, which is only correct for a single-angle PlayItem. A multi-angle PlayItem inserts number_of_angles(1) + flags(1) + (number_of_angles-1) angle references (10 bytes each) right after still_time, so its STN begins at 32 + 2 + (number_of_angles-1)*10. The fixed offset lands inside that angle block and misreads the stream counts as garbage (e.g. n_video=51 on Spider-Man 3 UHD 00245/00246.mpls, number_of_angles=2 => real offset 44), dropping every stream including the primary video. That makes DiscTitle::has_video() return false, which drops a multi-angle/seamless-branch feature from the disc's nav candidate set and loses it in main-title selection. Compute the STN offset from is_multi_angle (bit 4 of item[10]) + number_of _angles. Add a regression test building a 2-angle PlayItem and asserting the video stream is found past the angle block. Validated on a 92-disc real UHD/BD corpus (offline scan): 0 main-feature pick changes vs baseline, 3 multi-angle titles (Spider-Man 3 244/245/246) recover has_video. Adds examples/corpus_titledump.rs as the regression tool. --- examples/corpus_titledump.rs | 179 +++++++++++++++++++++++++++++++++++ src/mpls.rs | 117 +++++++++++++++++++---- 2 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 examples/corpus_titledump.rs diff --git a/examples/corpus_titledump.rs b/examples/corpus_titledump.rs new file mode 100644 index 00000000..a5e5be78 --- /dev/null +++ b/examples/corpus_titledump.rs @@ -0,0 +1,179 @@ +// Corpus title-shape dump for issue #45 fallback tuning. +// +// Runs the REAL offline scan (Disc::scan_image → sort_titles_by_main_feature) +// over a directory of decrypted ISOs and emits one NDJSON line per disc: +// every title's duration/size/clip-shape plus the rank flags the selector +// computed (nav/authoring/composite) and which title it picked (titles[0]). +// +// The point: measure, across many real discs, how cleanly any candidate +// discriminator separates the true main feature from play-all wrappers and +// seamless-branch decoys — so the #45 fallback is derived from data, not a +// guessed 0.85/0.90 constant. +// +// Usage: corpus_titledump [...] > corpus.ndjson + +use libfreemkv::disc::{Disc, ScanOptions}; +use libfreemkv::io::file_sector_source::FileSectorSource; +use libfreemkv::sector::SectorSource; +use std::path::{Path, PathBuf}; + +// scan_image wants the disc capacity in sectors; FileSectorSource knows it. + +fn jstr(s: &str) -> String { + let mut o = String::with_capacity(s.len() + 2); + o.push('"'); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)), + c => o.push(c), + } + } + o.push('"'); + o +} + +fn dump_one(path: &Path) -> String { + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?"); + let mut reader = match FileSectorSource::open(path) { + Ok(r) => r, + Err(e) => { + return format!( + "{{\"iso\":{},\"error\":\"open: {:?}\"}}", + jstr(name), + format!("{e}") + ); + } + }; + let cap = reader.capacity_sectors(); + let opts = ScanOptions::default(); + let disc = match Disc::scan_image(&mut reader, cap, &opts) { + Ok(d) => d, + Err(e) => { + return format!( + "{{\"iso\":{},\"error\":\"scan: {}\"}}", + jstr(name), + jstr(&format!("{e}")) + ); + } + }; + + // Recompute the rank flags the selector used so we can see WHY it ordered + // things the way it did. nav/authoring hint aren't available offline here + // (scan_with already applied them into titles[0]); pass None/None so the + // flags reflect the pure composite/video/size classification, which is the + // fallback we are tuning. + let ranks = Disc::rank_titles(&disc.titles, None, None); + + // pick = titles[0] (post-sort main feature). Also compute the naive + // "longest video title" as a ground-truth proxy for single-movie discs. + let pick_pl = disc.titles.first().map(|t| t.playlist_id).unwrap_or(0); + let longest_video_pl = disc + .titles + .iter() + .filter(|t| t.has_probable_video()) + .max_by(|a, b| a.duration_secs.partial_cmp(&b.duration_secs).unwrap()) + .map(|t| t.playlist_id); + + let mut titles_json = String::from("["); + for (i, t) in disc.titles.iter().enumerate() { + if i > 0 { + titles_json.push(','); + } + let r = &ranks[i]; + // Sum of the FULL durations of this title's distinct clips (each clip + // counted once at its clip-level duration). A play-all concat wrapper's + // title duration ≈ this sum; a seamless-branch feature's is dominated by + // one clip. This is the structural discriminator to evaluate. + let mut seen = std::collections::BTreeMap::<&str, f64>::new(); + for c in &t.clips { + seen.entry(c.clip_id.as_str()).or_insert(c.duration_secs); + } + let sum_distinct_clip_dur: f64 = seen.values().sum(); + let max_distinct_clip_dur: f64 = seen.values().cloned().fold(0.0, f64::max); + let n_distinct_clips = seen.len(); + let clip_ids: String = { + let mut s = String::from("["); + for (k, id) in seen.keys().enumerate() { + if k > 0 { + s.push(','); + } + s.push_str(&jstr(id)); + } + s.push(']'); + s + }; + titles_json.push_str(&format!( + "{{\"pl\":{},\"dur\":{:.1},\"size\":{},\"clips\":{},\"distinct_clips\":{},\ + \"sum_distinct_clip_dur\":{:.1},\"max_distinct_clip_dur\":{:.1},\ + \"has_video\":{},\"has_probable_video\":{},\ + \"nav\":{},\"authoring\":{},\"composite\":{},\"clip_ids\":{}}}", + t.playlist_id, + t.duration_secs, + t.size_bytes, + t.clips.len(), + n_distinct_clips, + sum_distinct_clip_dur, + max_distinct_clip_dur, + t.has_video(), + t.has_probable_video(), + r.nav, + r.authoring, + r.composite, + clip_ids, + )); + } + titles_json.push(']'); + + format!( + "{{\"iso\":{},\"format\":\"{:?}\",\"cap\":{},\"n_titles\":{},\ + \"pick_pl\":{},\"longest_video_pl\":{},\"pick_is_longest_video\":{},\"titles\":{}}}", + jstr(name), + disc.format, + cap, + disc.titles.len(), + pick_pl, + longest_video_pl + .map(|p| p.to_string()) + .unwrap_or_else(|| "null".into()), + longest_video_pl.map(|p| p == pick_pl).unwrap_or(false), + titles_json, + ) +} + +fn main() { + let dirs: Vec = std::env::args().skip(1).map(PathBuf::from).collect(); + if dirs.is_empty() { + eprintln!("usage: corpus_titledump [...]"); + std::process::exit(2); + } + let mut isos: Vec = Vec::new(); + for d in &dirs { + if d.is_file() { + isos.push(d.clone()); + continue; + } + if let Ok(rd) = std::fs::read_dir(d) { + for e in rd.flatten() { + let p = e.path(); + if p.extension() + .and_then(|s| s.to_str()) + .map(|s| s.eq_ignore_ascii_case("iso")) + .unwrap_or(false) + { + isos.push(p); + } + } + } + } + isos.sort(); + eprintln!("scanning {} ISOs", isos.len()); + for (i, p) in isos.iter().enumerate() { + eprintln!("[{}/{}] {}", i + 1, isos.len(), p.display()); + println!("{}", dump_one(p)); + } +} diff --git a/src/mpls.rs b/src/mpls.rs index 40f6e251..d9677255 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -5,6 +5,21 @@ //! stream info (video, audio, subtitle tracks). //! //! Format is documented in the BD-ROM PlayList (MPLS) specification. +//! +//! ## Multi-angle PlayItem STN offset (issue #45) +//! The STN (stream-number) table sits at PlayItem offset 32 for a single-angle +//! item, but a MULTI-ANGLE item (is_multi_angle = bit 4 of item[10]) inserts an +//! angle block right after still_time at offset 32: +//! number_of_angles(1) + flags(1) + (number_of_angles − 1) angle references, +//! each clip_name(5) + clip_codec_id(4) + ref_to_STC_id(1) = 10 bytes +//! (the first angle is the primary clip already at item[0..5]). So the real STN +//! begins at `32 + 2 + (number_of_angles − 1) * 10`. A fixed offset of 32 lands +//! INSIDE that block and misreads the stream counts as garbage (e.g. n_video = +//! 51 on Spider-Man 3 UHD 00245/00246.mpls, number_of_angles = 2 ⇒ real offset +//! 44), dropping every stream — the primary video included. That makes +//! `DiscTitle::has_video()` return false, which drops a multi-angle/seamless- +//! branch FEATURE from the disc's nav candidate set (see `Disc::scan_with`) and +//! loses it in main-title selection. `parse` computes the offset accordingly. use crate::error::{Error, Result}; @@ -150,25 +165,31 @@ pub fn parse(data: &[u8]) -> Result { let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]); let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]); - // Parse STN table from the first play item. PlayItem layout after out_time: - // UO_mask_table(8) + misc flags(1) + still_mode(1) + still_time(2), then - // STN_table starting at STN_OFFSET. - const STN_OFFSET: usize = 32; - // `>=`, not `>`: the 16-byte STN header spans item[STN_OFFSET..STN_OFFSET+16], so - // item.len() == STN_OFFSET + 16 already holds every header byte. Strict `>` demanded + // STN table offset: base 32, shifted past the multi-angle angle block when + // present (issue #45 — see the module `//!` header for the layout and why a + // fixed 32 misparses). is_multi_angle is bit 4 of item[10]. + let is_multi_angle = (item[10] & 0x10) != 0; + let stn_offset = if is_multi_angle && item.len() > 32 { + let number_of_angles = item[32] as usize; + 32 + 2 + number_of_angles.saturating_sub(1) * 10 + } else { + 32 + }; + // `>=`, not `>`: the 16-byte STN header spans item[stn_offset..stn_offset+16], so + // item.len() == stn_offset + 16 already holds every header byte. Strict `>` demanded // one extra byte, skipping a play item whose STN header ends exactly at the boundary (stream-less STN table). - if item_idx == 0 && item.len() >= STN_OFFSET + 16 { + if item_idx == 0 && item.len() >= stn_offset + 16 { // STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes - let n_video = item[STN_OFFSET + 4] as usize; - let n_audio = item[STN_OFFSET + 5] as usize; - let n_pg = item[STN_OFFSET + 6] as usize; - let n_ig = item[STN_OFFSET + 7] as usize; - let n_sec_audio = item[STN_OFFSET + 8] as usize; - let n_sec_video = item[STN_OFFSET + 9] as usize; - let n_pip_pg = item[STN_OFFSET + 10] as usize; - let n_dv = item[STN_OFFSET + 11] as usize; + let n_video = item[stn_offset + 4] as usize; + let n_audio = item[stn_offset + 5] as usize; + let n_pg = item[stn_offset + 6] as usize; + let n_ig = item[stn_offset + 7] as usize; + let n_sec_audio = item[stn_offset + 8] as usize; + let n_sec_video = item[stn_offset + 9] as usize; + let n_pip_pg = item[stn_offset + 10] as usize; + let n_dv = item[stn_offset + 11] as usize; - let mut spos = STN_OFFSET + 16; + let mut spos = stn_offset + 16; // Primary video for _ in 0..n_video { @@ -753,6 +774,70 @@ mod tests { ); } + // Regression for issue #45 (see module `//!` header): a multi-angle first + // PlayItem puts its STN past the angle block (2 angles ⇒ offset 44); a fixed + // offset of 32 misreads it and drops the video. Assert the video is found. + #[test] + fn multi_angle_first_play_item_stn_is_located_after_the_angle_block() { + let in_time = 0u32; + let out_time = 9_000_000u32; + let number_of_angles = 2u8; + + let mut item = Vec::new(); + item.extend_from_slice(b"00001"); // [0..5] clip_id (angle 1 = primary) + item.extend_from_slice(b"M2TS"); // [5..9] codec_id + item.push(0); // [9] reserved + item.push(0x10); // [10] is_multi_angle (bit 4), connection_condition = 0 + item.push(0); // [11] stc_id + item.extend_from_slice(&in_time.to_be_bytes()); // [12..16] + item.extend_from_slice(&out_time.to_be_bytes()); // [16..20] + item.extend_from_slice(&[0u8; 8]); // [20..28] UO mask + item.push(0); // [28] misc flags + item.push(0); // [29] still_mode + item.extend_from_slice(&[0u8; 2]); // [30..32] still_time + // Angle block: number_of_angles(1) + flags(1) + (angles-1) refs * 10 bytes. + item.push(number_of_angles); // [32] + item.push(0); // [33] is_different_audios / is_seamless_angle_change + item.extend_from_slice(b"00002"); // angle 2 clip_name(5) + item.extend_from_slice(b"M2TS"); // angle 2 codec_id(4) + item.push(0); // angle 2 ref_to_STC_id(1) -> STN now begins at offset 44 + // STN table: length(2) + reserved(2) + counts(8) + reserved(4) + entries. + let stn_start = item.len(); + item.extend_from_slice(&[0u8; 2]); // STN length placeholder + item.extend_from_slice(&[0u8; 2]); // reserved + item.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0]); // n_video=1, rest 0 + item.extend_from_slice(&[0u8; 4]); // reserved + item.extend_from_slice(&build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12))); + let stn_len = (item.len() - stn_start - 2) as u16; + item[stn_start..stn_start + 2].copy_from_slice(&stn_len.to_be_bytes()); + + // Wrap in the file + PlayList headers. + let mut buf = Vec::new(); + buf.extend_from_slice(b"MPLS0200"); + buf.extend_from_slice(&40u32.to_be_bytes()); // playlist_start + buf.extend_from_slice(&[0u8; 28]); // mark_start(0) + padding to 40 + let pl_start = buf.len(); + buf.extend_from_slice(&[0u8; 4]); // PlayList length placeholder + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&1u16.to_be_bytes()); // num_play_items + buf.extend_from_slice(&[0u8; 2]); // num_sub_paths + buf.extend_from_slice(&(item.len() as u16).to_be_bytes()); + buf.extend_from_slice(&item); + let pl_len = (buf.len() - pl_start - 4) as u32; + buf[pl_start..pl_start + 4].copy_from_slice(&pl_len.to_be_bytes()); + + let playlist = parse(&buf).expect("multi-angle MPLS must parse"); + assert_eq!(playlist.play_items.len(), 1); + assert_eq!( + playlist.streams.len(), + 1, + "the primary video must be found past the angle block, not misread as garbage" + ); + assert_eq!(playlist.streams[0].stream_type, 1, "it is a video stream"); + assert_eq!(playlist.streams[0].pid, 0x1011); + assert_eq!(playlist.streams[0].coding_type, 0x24); // HEVC + } + #[test] fn parse_invalid_magic() { let mut data = build_mpls(&[(b"00001", 1, 0, 9000000)], (0, 0, 0, 0, 0, 0, 0, 0), &[]);