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.
This commit is contained in:
Matthew Jackson
2026-09-18 16:38:45 -07:00
parent 013f27eede
commit 48fb9b2cf6
2 changed files with 280 additions and 16 deletions
+179
View File
@@ -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 <iso_dir> [<iso_dir>...] > 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<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if dirs.is_empty() {
eprintln!("usage: corpus_titledump <iso_dir> [<iso_dir>...]");
std::process::exit(2);
}
let mut isos: Vec<PathBuf> = 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));
}
}
+101 -16
View File
@@ -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<Playlist> {
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), &[]);