diff --git a/src/bdnav/bdjo.rs b/src/bdnav/bdjo.rs new file mode 100644 index 00000000..5c1f11a9 --- /dev/null +++ b/src/bdnav/bdjo.rs @@ -0,0 +1,432 @@ +//! Minimal, read-only parser for `/BDMV/BDJO/*.bdjo` — the BD-J Object files. +//! +//! Only the Application Management Table (AMT) is decoded, and only the four +//! fields the issue #45 Tier-2 menu-walk needs from each application record: +//! its `application_control_code` (1 = AUTOSTART), `base_directory`, +//! `classpath_extension`, and `initial_class` (the Xlet's fully-qualified class +//! name). Everything else in the file is skipped by width. +//! +//! Layout mirrors libbluray's `bdjo_parse.c` (`_parse_header` + +//! `_parse_terminal_info` + `_parse_app_cache_info` + +//! `_parse_accessible_playlists` + `_parse_app_management_table`): the sections +//! are laid out sequentially right after a fixed 48-byte header (8-byte +//! magic+version, then a 40-byte section-address table that real players skip). +//! +//! This is a DOCUMENTED BINARY FORMAT read as data, never executed. Every field +//! is bounds-checked and any malformed input yields `None` — panic-free like +//! [`crate::bdnav::index`]. Tier-2 falls back to scanning every jar when this +//! returns `None`. + +/// One application record from a BDJO Application Management Table — only the +/// fields the menu-walk consumes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BdjoApp { + /// `application_control_code`: 1 = AUTOSTART, 2 = PRESENT. + pub control_code: u8, + /// `base_directory` — the primary jar id (e.g. "00000"), naming + /// `/BDMV/JAR/.jar`. + pub base_dir: String, + /// `classpath_extension` — additional `;`-separated jar ids on the + /// classpath, or empty. + pub classpath_extension: String, + /// `initial_class` — the autostart Xlet's fully-qualified class name + /// (e.g. "com.foxbd.StandardMenuXlet"). + pub initial_class: String, +} + +impl BdjoApp { + /// Whether this application autostarts (the one whose Xlet drives the disc). + pub fn is_autostart(&self) -> bool { + self.control_code == AUTOSTART + } + + /// The jar ids that make up this app's classpath: `base_directory` first, + /// then each `;`-separated `classpath_extension` entry. Empty tokens are + /// dropped. These name `/BDMV/JAR/.jar`. + pub fn jar_ids(&self) -> Vec { + let mut out = Vec::new(); + let base = self.base_dir.trim(); + if !base.is_empty() { + out.push(base.to_string()); + } + for part in self.classpath_extension.split(';') { + let part = part.trim(); + if !part.is_empty() && !out.iter().any(|e| e == part) { + out.push(part.to_string()); + } + } + out + } +} + +const AUTOSTART: u8 = 1; + +// "BDJO" magic. The 4-byte version that follows ("0100"/"0200"/"0240"/"0300") +// is read but not validated — a future revision must still let Tier-2 fall back +// gracefully rather than mis-reject a readable table. +const MAGIC: u32 = u32::from_be_bytes(*b"BDJO"); + +// Cap on applications parsed from one AMT. `number_of_applications` is a u8, so +// 255 is the hard ceiling already; the cap is a belt-and-suspenders bound. +const MAX_APPS: usize = 256; + +/// Parse a `.bdjo` file's Application Management Table. Returns the list of +/// application records (in file order), or `None` on any structural problem. +pub(crate) fn parse(data: &[u8]) -> Option> { + let mut r = BitReader::new(data); + + // ── Header ────────────────────────────────────────────────────────────── + if r.read(32)? as u32 != MAGIC { + return None; + } + r.skip(32)?; // version_number (not load-bearing for resolution) + r.skip(40 * 8)?; // section-address table (players read sections sequentially) + + // ── TerminalInfo ──────────────────────────────────────────────────────── + r.skip(32)?; // length + r.skip(5 * 8)?; // default_font (5 bytes) + r.skip(4 + 1 + 1)?; // initial_havi_config_id + menu_call_mask + title_search_mask + r.skip(34)?; // padding + + // ── AppCacheInfo ────────────────────────────────────────────────────────── + r.skip(32)?; // length + let num_item = r.read(8)? as usize; + r.skip(8)?; // padding + for _ in 0..num_item { + r.skip(12 * 8)?; // each item: type(1) + ref_to_name(5) + lang_code(3) + pad(3) + } + + // ── AccessiblePlaylists ─────────────────────────────────────────────────── + r.skip(32)?; // length + let num_pl = r.read(11)? as usize; + r.skip(1 + 1)?; // access_to_all_flag + autostart_first_playlist_flag + r.skip(19)?; // padding + for _ in 0..num_pl { + r.skip(6 * 8)?; // each: name(5) + pad(1) + } + + // ── ApplicationManagementTable ──────────────────────────────────────────── + r.skip(32)?; // length + let num_app = r.read(8)? as usize; + r.skip(8)?; // padding + if num_app > MAX_APPS { + return None; + } + let mut apps = Vec::with_capacity(num_app); + for _ in 0..num_app { + apps.push(parse_app(&mut r)?); + } + Some(apps) +} + +// Parse one Application() record (`_parse_bdjo_app` in libbluray). +fn parse_app(r: &mut BitReader<'_>) -> Option { + let control_code = r.read(8)? as u8; + r.skip(4)?; // application_type + r.skip(4)?; // reserved + r.skip(32)?; // organization_id + r.skip(16)?; // application_id + r.skip(80)?; // descriptor tag + length (10 bytes) + + // application descriptor + let num_profile = r.read(4)? as usize; + r.skip(12)?; // padding + for _ in 0..num_profile { + r.skip(6 * 8)?; // profile_number(2) + major/minor/micro(3) + pad(1) + } + + r.skip(8)?; // application_priority + r.skip(2 + 2 + 4)?; // binding + visibility + reserved + + // application_name section: u16 data_length, then that many bytes, word-aligned. + let names_len = r.read(16)? as usize; + r.skip(names_len * 8)?; + if !names_len.is_multiple_of(2) { + r.skip(8)?; + } + + // icon_locator (word-aligned string), then icon_flags(16). + read_app_string(r)?; + r.skip(16)?; + + let base_dir = read_app_string(r)?; + let classpath_extension = read_app_string(r)?; + let initial_class = read_app_string(r)?; + + // application_parameters: u8 data_length, that many bytes, word-aligned. + let params_len = r.read(8)? as usize; + r.skip(params_len * 8)?; + if params_len.is_multiple_of(2) { + r.skip(8)?; + } + + Some(BdjoApp { + control_code, + base_dir, + classpath_extension, + initial_class, + }) +} + +// A word-aligned length-prefixed string (`_read_app_string`): u8 length, then +// `length` bytes, then one pad byte when `length` is EVEN. Bytes are Latin-1 +// decoded (FQCNs/jar ids are ASCII); trailing NULs are trimmed. +fn read_app_string(r: &mut BitReader<'_>) -> Option { + let len = r.read(8)? as usize; + let mut s = String::with_capacity(len); + for _ in 0..len { + s.push(r.read(8)? as u8 as char); + } + if len.is_multiple_of(2) { + r.skip(8)?; + } + Some(s.trim_end_matches('\0').to_string()) +} + +// ── MSB-first bit reader ───────────────────────────────────────────────────── +// Mirrors libbluray's BITSTREAM: bits are consumed most-significant-first. +// Every read/skip is bounds-checked and returns `None` past the end. +struct BitReader<'a> { + data: &'a [u8], + bit_pos: usize, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8]) -> Self { + BitReader { data, bit_pos: 0 } + } + + fn total_bits(&self) -> usize { + self.data.len() * 8 + } + + // Read up to 64 bits, MSB-first. `None` if fewer than `n` bits remain. + fn read(&mut self, n: u32) -> Option { + if n == 0 { + return Some(0); + } + if n > 64 { + return None; + } + let end = self.bit_pos.checked_add(n as usize)?; + if end > self.total_bits() { + return None; + } + let mut v: u64 = 0; + for _ in 0..n { + let byte = self.data[self.bit_pos >> 3]; + let bit = (byte >> (7 - (self.bit_pos & 7))) & 1; + v = (v << 1) | bit as u64; + self.bit_pos += 1; + } + Some(v) + } + + // Advance `n` bits. `None` if that would run past the end. + fn skip(&mut self, n: usize) -> Option<()> { + let end = self.bit_pos.checked_add(n)?; + if end > self.total_bits() { + return None; + } + self.bit_pos = end; + Some(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Bit reader unit tests ────────────────────────────────────────────── + #[test] + fn bit_reader_reads_msb_first_and_bounds_checks() { + let data = [0b1011_0010u8, 0b0100_0001]; + let mut r = BitReader::new(&data); + assert_eq!(r.read(1), Some(1)); + assert_eq!(r.read(3), Some(0b011)); + assert_eq!(r.read(4), Some(0b0010)); + assert_eq!(r.read(8), Some(0b0100_0001)); + // Nothing left. + assert_eq!(r.read(1), None); + assert_eq!(r.read(0), Some(0)); + } + + #[test] + fn bit_reader_skip_past_end_is_none() { + let data = [0u8; 2]; + let mut r = BitReader::new(&data); + assert_eq!(r.skip(16), Some(())); + assert_eq!(r.skip(1), None); + } + + // ── Fixture builder ───────────────────────────────────────────────────── + // Byte-aligned BDJO builder for a single-app AMT. All bit fields here happen + // to fall on byte boundaries, so the fixture can be assembled from bytes. + struct AppSpec { + control_code: u8, + base_dir: &'static str, + classpath_extension: &'static str, + initial_class: &'static str, + } + + fn push_app_string(buf: &mut Vec, s: &str) { + buf.push(s.len() as u8); + buf.extend_from_slice(s.as_bytes()); + if s.len().is_multiple_of(2) { + buf.push(0); // word-align pad on even length + } + } + + fn build_bdjo(apps: &[AppSpec]) -> Vec { + let mut b = Vec::new(); + // Header + b.extend_from_slice(b"BDJO"); + b.extend_from_slice(b"0200"); + b.extend_from_slice(&[0u8; 40]); // section-address table + + // TerminalInfo: length(4) + default_font(5) + 1 byte (havi+masks) + pad(34 bits) + b.extend_from_slice(&[0u8; 4]); + b.extend_from_slice(&[0u8; 5]); + // 4+1+1 = 6 bits then 34 bits padding = 40 bits = 5 bytes total. + b.extend_from_slice(&[0u8; 5]); + + // AppCacheInfo: length(4) + num_item(1)=0 + pad(1) + b.extend_from_slice(&[0u8; 4]); + b.push(0); + b.push(0); + + // AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)] = 4 bytes, num_pl=0 + b.extend_from_slice(&[0u8; 4]); + b.extend_from_slice(&[0u8; 4]); + + // AppManagementTable: length(4) + num_app(1) + pad(1) + b.extend_from_slice(&[0u8; 4]); + b.push(apps.len() as u8); + b.push(0); + + for a in apps { + b.push(a.control_code); // control_code(8) + b.push(0); // type(4)+reserved(4) + b.extend_from_slice(&[0u8; 4]); // org_id(32) + b.extend_from_slice(&[0u8; 2]); // app_id(16) + b.extend_from_slice(&[0u8; 10]); // descriptor tag+length(80) + b.push(0); // num_profile(4)+pad(4)... wait 4+12=16 bits + b.push(0); // (num_profile nibble high, then 12 bits pad) => 2 bytes, 0 profiles + b.push(0); // priority(8) + b.push(0); // binding(2)+visibility(2)+reserved(4) + // application_name: data_length(16)=0, no word-align (even 0) + b.extend_from_slice(&[0u8; 2]); + // icon_locator (empty word-aligned string): len=0 + pad + push_app_string(&mut b, ""); + b.extend_from_slice(&[0u8; 2]); // icon_flags(16) + push_app_string(&mut b, a.base_dir); + push_app_string(&mut b, a.classpath_extension); + push_app_string(&mut b, a.initial_class); + // application_parameters: data_length(8)=0 + word-align (even) pad + b.push(0); + b.push(0); + } + b + } + + #[test] + fn parses_autostart_app_fqcn_and_jar_ids() { + let bytes = build_bdjo(&[AppSpec { + control_code: 1, + base_dir: "00000", + classpath_extension: "00001;00002", + initial_class: "com.foxbd.StandardMenuXlet", + }]); + let apps = parse(&bytes).expect("parses"); + assert_eq!(apps.len(), 1); + let a = &apps[0]; + assert!(a.is_autostart()); + assert_eq!(a.initial_class, "com.foxbd.StandardMenuXlet"); + assert_eq!(a.base_dir, "00000"); + assert_eq!(a.jar_ids(), vec!["00000", "00001", "00002"]); + } + + #[test] + fn parses_multiple_apps_and_finds_the_autostart_one() { + let bytes = build_bdjo(&[ + AppSpec { + control_code: 2, // PRESENT, not autostart + base_dir: "00009", + classpath_extension: "", + initial_class: "com.studio.Helper", + }, + AppSpec { + control_code: 1, // AUTOSTART + base_dir: "00000", + classpath_extension: "", + initial_class: "com.studio.MainXlet", + }, + ]); + let apps = parse(&bytes).expect("parses"); + assert_eq!(apps.len(), 2); + let auto: Vec<&BdjoApp> = apps.iter().filter(|a| a.is_autostart()).collect(); + assert_eq!(auto.len(), 1); + assert_eq!(auto[0].initial_class, "com.studio.MainXlet"); + assert_eq!(auto[0].jar_ids(), vec!["00000"]); + } + + #[test] + fn rejects_bad_magic() { + let mut bytes = build_bdjo(&[AppSpec { + control_code: 1, + base_dir: "00000", + classpath_extension: "", + initial_class: "X", + }]); + bytes[0..4].copy_from_slice(b"NOPE"); + assert!(parse(&bytes).is_none()); + } + + #[test] + fn truncation_yields_none_never_panics() { + let bytes = build_bdjo(&[AppSpec { + control_code: 1, + base_dir: "00000", + classpath_extension: "", + initial_class: "com.studio.MainXlet", + }]); + // Every truncation length must return None (or Some), never panic. + for cut in 0..bytes.len() { + let _ = parse(&bytes[..cut]); + } + } + + #[test] + fn parse_never_panics_on_random_bytes() { + let mut state: u64 = 0xB0D0_DEAD_BEEF_1234u64; + for _ in 0..500 { + // xorshift64* + let mut x = state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + state = x; + let len = (x % 300) as usize; + let mut buf = vec![0u8; len]; + for (i, b) in buf.iter_mut().enumerate() { + *b = ((x >> (i % 57)) & 0xFF) as u8; + } + // Give some of them a valid magic to exercise deeper paths. + if len >= 4 && x & 1 == 0 { + buf[0..4].copy_from_slice(b"BDJO"); + } + let _ = parse(&buf); + } + } + + #[test] + fn jar_ids_dedups_and_skips_empties() { + let a = BdjoApp { + control_code: 1, + base_dir: "00000".into(), + classpath_extension: "00000;;00003; ".into(), + initial_class: "X".into(), + }; + assert_eq!(a.jar_ids(), vec!["00000", "00003"]); + } +} diff --git a/src/bdnav/mod.rs b/src/bdnav/mod.rs index 43021eb1..c9f94414 100644 --- a/src/bdnav/mod.rs +++ b/src/bdnav/mod.rs @@ -7,6 +7,7 @@ //! //! Contract: read-only, bounded, and never panics or hard-fails. +pub(crate) mod bdjo; pub(crate) mod index; pub(crate) mod mobj; pub(crate) mod vm; diff --git a/src/labels/fox.rs b/src/labels/fox.rs index 81c3c505..2a290c10 100644 --- a/src/labels/fox.rs +++ b/src/labels/fox.rs @@ -155,12 +155,25 @@ pub(crate) fn labels_from_dcx(text: &str) -> Vec { labels } -// Pick the richest `` element (ties to first) — -// Fox presses one per regional variant. Returning ONE avoids merging two -// STN tables. See docs/fox.md for full rationale. +// A feature runs at least this long (`durs` is seconds — durs=7628 is a 2h7m +// feature). A stated sub-minute `name="feature"` is a decoy and is never +// selected; the guard is inert when no `durs` is present. Mirrors paramount. +const MIN_FEATURE_SECS: u64 = 60; + +// The `durs` (seconds) of a `` element, digits only, when stated. +fn playlist_duration_secs(element: &str) -> Option { + let v = xml::attr(element, "durs")?; + let digits: String = v.chars().filter(|c| c.is_ascii_digit()).collect(); + digits.parse::().ok().filter(|&d| d > 0) +} + +// Pick the richest `` (most nested streams; a `durs` +// tiebreak breaks a count tie by longest time; first feature wins a full tie; a +// stated sub-minute feature is skipped). See docs/fox.md for full rationale. fn select_feature_playlist(text: &str) -> Option<&str> { - let mut best: Option<&str> = None; - let mut best_streams = 0usize; + // rank = (nested stream count, durs seconds). None durs sorts as 0 so it + // never displaces a stream-count-equal sibling that states a longer time. + let mut best: Option<(&str, (usize, u64))> = None; let mut from = 0; while let Some((s, e)) = xml::find_element(text, "playlist", from) { let element = &text[s..e]; @@ -173,16 +186,21 @@ fn select_feature_playlist(text: &str) -> Option<&str> { if !is_feature { continue; } + let dur = playlist_duration_secs(element); + // A stated sub-minute feature is a decoy — never the real feature. + if dur.is_some_and(|d| d < MIN_FEATURE_SECS) { + continue; + } let streams = count_elements(element, "audio") + count_elements(element, "subtitle"); - // First feature wins the tie (matches the registry's first-wins rule); - // a strictly richer table displaces it. `best.is_none()` so a feature - // with zero nested streams is still selected — its id feeds the hint. - if best.is_none() || streams > best_streams { - best_streams = streams; - best = Some(element); + let rank = (streams, dur.unwrap_or(0)); + // Strictly-better rank displaces; a full tie keeps the first feature. + // `best.is_none()` so a feature with zero nested streams is still + // selected — its id feeds the hint. + if best.is_none_or(|(_, br)| rank > br) { + best = Some((element, rank)); } } - best + best.map(|(el, _)| el) } /// The authoring id (digits only, e.g. "00800") of the selected feature @@ -485,6 +503,41 @@ mod tests { assert_eq!(feature_playlist_id(menus_only), None); } + /// On an equal nested-stream count, the longer `durs` (seconds) wins the + /// tiebreak rather than document order. + #[test] + fn feature_duration_breaks_a_stream_count_tie() { + let doc = r#" + + + + + "#; + assert_eq!(feature_playlist_id(doc), Some("00900".to_string())); + } + + /// A stated sub-minute `name="feature"` is a decoy and is skipped in favour + /// of the real (longer) feature. + #[test] + fn sub_minute_feature_is_skipped() { + let doc = r#" + + + + + "#; + // 00050 has more nested streams but is sub-minute → rejected; 00800 wins. + assert_eq!(feature_playlist_id(doc), Some("00800".to_string())); + } + /// A feature playlist with no nested streams (an authoring edge) produces /// no labels rather than a spurious empty-slot entry. #[test] diff --git a/src/labels/jar.rs b/src/labels/jar.rs index 7e74150f..8959f2dd 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -18,6 +18,11 @@ use zip::ZipArchive; // pre-sizing. See docs/jar.md — MAX_CLASS_BYTES rationale. const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024; +// Cap on bytes read from one non-`.class` resource entry (the dcx.xml / +// playlists.xml / *.properties manifests the Tier-1 sweep reads). Same +// attacker-controlled-size defence as MAX_CLASS_BYTES — see docs/jar.md. +const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024; + /// In-memory zip archive: backed by a `Vec` read from UDF. Owns /// the buffer; callers pass it to [`has_path_prefix`], [`for_each_class`], /// etc. @@ -119,6 +124,44 @@ where None } +/// Iterate every NON-`.class` entry in the jar, read its bytes (bounded by +/// [`MAX_RESOURCE_BYTES`]), and call `f` with `(entry_name, &bytes)`. Returns +/// the first `Some(R)` the callback produces, or `None` when every resource was +/// visited without a hit. +/// +/// This is the Tier-1 menu-walk sweep: newer discs embed the same +/// `dcx.xml`/`playlists.xml` manifests INSIDE a jar rather than as loose +/// `/BDMV/JAR//` files. Directory entries and `.class` files are skipped; +/// unreadable entries are silently ignored (robustness over completeness). +pub fn try_each_resource(archive: &mut Jar, mut f: F) -> Option +where + F: FnMut(&str, &[u8]) -> Option, +{ + for i in 0..archive.len() { + let Ok(entry) = archive.by_index(i) else { + continue; + }; + let name = entry.name().to_string(); + if name.ends_with('/') || name.to_ascii_lowercase().ends_with(".class") { + continue; + } + // Declared uncompressed size is attacker-controlled — grow the buffer + // incrementally and cap the read rather than pre-sizing from it. + let mut bytes = Vec::new(); + if entry + .take(MAX_RESOURCE_BYTES) + .read_to_end(&mut bytes) + .is_err() + { + continue; + } + if let Some(r) = f(&name, &bytes) { + return Some(r); + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -292,4 +335,36 @@ mod tests { for_each_class(&mut jar, |_, _| visited += 1); assert_eq!(visited, 1); } + + #[test] + fn try_each_resource_reads_non_class_entries_and_skips_classes() { + let xml = b""; + // A .class entry must be skipped; the .xml resource must be surfaced. + let mut jar = { + use std::io::{Cursor, Write as _}; + let mut buf = Vec::new(); + { + let mut w = zip::ZipWriter::new(Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + w.start_file("com/x/Menu.class", opts).unwrap(); + w.write_all(MINIMAL_CLASS).unwrap(); + w.start_file("00000/dcx.xml", opts).unwrap(); + w.write_all(xml).unwrap(); + w.finish().unwrap(); + } + ZipArchive::new(Cursor::new(buf)).expect("valid zip") + }; + + let mut seen: Vec = Vec::new(); + let found: Option> = try_each_resource(&mut jar, |name, bytes| { + seen.push(name.to_string()); + name.ends_with("dcx.xml").then(|| bytes.to_vec()) + }); + assert_eq!(found.as_deref(), Some(&xml[..])); + assert!( + !seen.iter().any(|n| n.ends_with(".class")), + "a .class entry must never be offered as a resource" + ); + } } diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 16438c16..c41d9dd7 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -8,6 +8,7 @@ //! (set [`ParseResult::confidence`] to drive tie-breaking) //! 4. Add `mod myformat;` below and one line to `PARSERS` array +mod bdj_feature; mod bdmt; pub(crate) mod class_reader; pub mod clpi_audit; @@ -252,13 +253,35 @@ pub fn apply( udf: &UdfFs, titles: &mut [DiscTitle], ) -> Option { - let (labels, feature_playlist) = + let (labels, winner_hint) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf))) .unwrap_or_default(); if !labels.is_empty() { apply_labels(&labels, titles); } - feature_playlist.filter(|h| !h.is_empty()) + // The feature hint rides its own pass, independent of whether any parser + // produced labels (like bdmt): a jar-only disc still yields a hint. Wrapped + // separately so a menu-walk fault can't lose an already-extracted hint. + let hint = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + resolve_feature_hint(reader, udf, winner_hint) + })) + .ok() + .flatten(); + hint.filter(|h| !h.is_empty()) +} + +// The disc's feature-playlist hint: the winning parser's manifest hint when it +// carries one, else the generic BD-J menu-walk (`bdj_feature::resolve`). Kept +// separate from extraction so a hint survives an empty/absent label set. +fn resolve_feature_hint( + reader: &mut dyn SectorSource, + udf: &UdfFs, + winner_hint: Option, +) -> Option { + if let Some(hint) = winner_hint.filter(|h| !h.is_empty()) { + return Some(hint); + } + bdj_feature::resolve(reader, udf) } // Min streams of one type before a language sequence anchors the label @@ -3376,3 +3399,77 @@ mod clpi_orphan_tests { assert_eq!(labels.len(), 1); } } + +// ── apply(): independent feature-hint pass (issue #45 menu-walk) ───────────── + +#[cfg(test)] +mod feature_hint_pass_tests { + use super::*; + use crate::udf::fixture::*; + use std::io::{Cursor, Write as _}; + + // Zip entries into an in-memory jar (Stored, no compression). + fn build_jar(entries: &[(&str, Vec)]) -> Vec { + let mut buf = Vec::new(); + { + let mut w = zip::ZipWriter::new(Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + for (name, data) in entries { + w.start_file(*name, opts).unwrap(); + w.write_all(data).unwrap(); + } + w.finish().unwrap(); + } + buf + } + + /// A jar-only disc — no loose manifest, no vendor labels, no parser matches — + /// still yields a feature hint, because the menu-walk hint pass runs + /// independently of `labels.is_empty()`. This is the whole point of routing + /// the hint through `resolve_feature_hint` rather than dropping it with the + /// (empty) label set. The embedded `playlists.xml` names the feature 00800. + #[test] + fn apply_returns_hint_for_a_jar_only_disc_with_no_labels() { + let xml = br#" + + + "#; + let jar = build_jar(&[ + ("com/studio/Menu.class", Vec::new()), // an entry, not a parseable class + ("00000/playlists.xml", xml.to_vec()), + ]); + let jar_dir = DirSpec { + name: "JAR".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files: vec![file_with("00000.jar", 32, 4000, jar, true)], + subdirs: vec![], + }; + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![jar_dir], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![bdmv], + }; + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + + // No titles to label; the hint pass must still fire. + let mut titles: Vec = Vec::new(); + let hint = + apply(&mut disc, &udf, &mut titles).expect("a jar-only disc still yields a hint"); + assert_eq!(hint.playlist_id, Some(800)); + assert_eq!(hint.filename.as_deref(), Some("00800.mpls")); + } +} diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index bb240213..c033ca43 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -212,37 +212,137 @@ fn labels_from_feature(feature: &str) -> Vec { labels } -/// Find the feature playlist element (the one with the most non-empty -/// audio slots). +/// The feature playlist hint for a whole `playlists.xml` document — selects the +/// feature element and derives its id/filename. Exposed for the generic BD-J +/// menu-walk (`bdj_feature`) Tier-1 sweep, which finds this manifest embedded +/// inside a jar rather than as a loose file. +pub(crate) fn feature_hint_from_xml(text: &str) -> Option { + let feature = find_feature_playlist(text)?; + feature_hint(&feature) +} + +// A playlist must run at least this long (seconds) to be the feature — kills the +// `_Start_Angle` 2-second decoy (Sony SM3 UHD id 00243). Applied only when a +// duration is actually stated; absent, it stays inert. +const MIN_FEATURE_SECS: u64 = 60; + +// A `` element's stated running time in seconds, if any. Read from the +// first present of a set of duration-like attributes (the corpus is not a spec; +// `durs` matches the sibling Fox manifest's seconds convention). Digits only. +fn playlist_duration_secs(element: &str) -> Option { + for key in ["duration", "durs", "dur", "runtime", "length", "len"] { + if let Some(v) = xml::attr(element, key) { + let digits: String = v.chars().filter(|c| c.is_ascii_digit()).collect(); + if let Ok(secs) = digits.parse::() + && secs > 0 + { + return Some(secs); + } + } + } + None +} + +// A feature-selection candidate: the element text, its non-empty audio-slot +// count, and its stated duration (seconds) when known. +struct Candidate { + element: String, + aud: usize, + dur: Option, +} + +impl Candidate { + // A candidate whose duration is stated AND sub-minute can never be the + // feature — the _Start_Angle decoy guard. + fn is_sub_minute(&self) -> bool { + self.dur.is_some_and(|d| d < MIN_FEATURE_SECS) + } +} + +/// Find the feature playlist element. Order, refined for Sony SM3 UHD: +/// 1. exact `name="Feature"` (case-insensitive) — longest-duration among any, +/// else first; +/// 2. else any name matching `/feature/i` (`_Feature`, `Feature_A`, …) — +/// longest-duration among them, else the most-audio one; +/// 3. else the most-audio playlist overall, breaking ties by longest duration. +/// +/// A playlist whose stated duration is sub-minute is never returned (the +/// `_Start_Angle` decoy). fn find_feature_playlist(text: &str) -> Option { - let mut best: Option = None; - let mut best_aud_count = 0; + let mut exact: Vec = Vec::new(); + let mut feature_like: Vec = Vec::new(); + let mut all: Vec = Vec::new(); let mut from = 0; while let Some((start, end)) = xml::find_element(text, "playlist", from) { let element = &text[start..end]; - - // Prefer name="Feature" explicitly. - if let Some(name) = xml::attr(element, "name") - && name.eq_ignore_ascii_case("Feature") - { - return Some(element.to_string()); - } - - // Otherwise pick the one with the most audio streams. Count only - // non-empty slots so a malformed `aud=",,,,,"` can't outscore a - // legitimate feature. - if let Some(aud) = xml::attr(element, "aud") { - let count = aud.split(',').filter(|s| !s.trim().is_empty()).count(); - if count > best_aud_count { - best_aud_count = count; - best = Some(element.to_string()); - } - } - from = end; + + // Count only non-empty audio slots so a malformed `aud=",,,,,"` can't + // outscore a legitimate feature. + let aud = xml::attr(element, "aud") + .map(|a| a.split(',').filter(|s| !s.trim().is_empty()).count()) + .unwrap_or(0); + let dur = playlist_duration_secs(element); + let make = || Candidate { + element: element.to_string(), + aud, + dur, + }; + + match xml::attr(element, "name") { + Some(name) if name.eq_ignore_ascii_case("Feature") => exact.push(make()), + Some(name) if name.to_ascii_lowercase().contains("feature") => { + feature_like.push(make()) + } + _ => {} + } + all.push(make()); } - best + + // Tier 1: exact name="Feature" — longest duration, else first. A + // zero-audio exact feature is still valid (its id feeds the hint). + if let Some(el) = pick(&exact, Key::Duration, false) { + return Some(el); + } + // Tier 2: /feature/i names (Sony `_Feature`, `Feature_A`, `Feature_B`) — + // longest duration among them, else most audio. + if let Some(el) = pick(&feature_like, Key::Duration, true) { + return Some(el); + } + // Tier 3: most audio across all playlists, longest duration as the tiebreak. + pick(&all, Key::Audio, true) +} + +// Which signal dominates candidate ranking: duration (tiers 1-2) or audio-slot +// count (tier 3). The other is the tiebreak. +#[derive(Clone, Copy)] +enum Key { + Duration, + Audio, +} + +// Choose the best candidate under `key`: skip sub-minute decoys; first-wins on a +// full tie (strict `>`). When `require_audio`, a candidate needs at least one +// audio slot to be eligible. `None` duration sorts below any stated duration. +fn pick(cands: &[Candidate], key: Key, require_audio: bool) -> Option { + let rank = |c: &Candidate| match key { + Key::Duration => (c.dur, c.aud as u64), + Key::Audio => (Some(c.aud as u64), c.dur.unwrap_or(0)), + }; + let mut best: Option<&Candidate> = None; + for c in cands { + if c.is_sub_minute() { + continue; + } + if require_audio && c.aud == 0 { + continue; + } + if best.is_none_or(|b| rank(c) > rank(b)) { + best = Some(c); + } + } + best.map(|c| c.element.clone()) } #[cfg(test)] @@ -750,4 +850,69 @@ mod tests { "first playlist must win a tie, got: {feature}" ); } + + // Sony SM3 UHD regression: `_Start_Angle` (id 00243, 2s) shares the + // feature's audio slots and comes first — `/feature/i` matching plus the + // sub-minute guard now reject the decoy the old rule picked. + #[test] + fn feature_selection_rejects_start_angle_decoy() { + let xml = r#" + + + + "#; + let feature = find_feature_playlist(xml).expect("a feature is found"); + assert!( + !feature.contains("_Start_Angle"), + "the 2-second angle decoy must never be the feature: {feature}" + ); + // Tie between the two /feature/i playlists → first wins (_Feature, 00800). + let h = feature_hint_from_xml(xml).expect("hint"); + assert_eq!(h.playlist_id, Some(800)); + assert_eq!(h.filename.as_deref(), Some("00800.mpls")); + } + + // Among /feature/i playlists, the longest duration wins — not document order. + #[test] + fn feature_like_longest_duration_wins() { + let xml = r#" + + + "#; + let h = feature_hint_from_xml(xml).expect("hint"); + assert_eq!( + h.playlist_id, + Some(802), + "the longer /feature/i playlist wins" + ); + } + + // The sub-minute guard also applies when the ONLY /feature/i playlist is a + // decoy: it is rejected and selection falls through to the real feature. + #[test] + fn sub_minute_feature_name_is_rejected() { + let xml = r#" + + + "#; + let feature = find_feature_playlist(xml).expect("a feature is found"); + // Feature_Trailer is /feature/i but sub-minute → rejected; tier 3 picks + // MainMovie (equal audio, far longer duration). + assert!(feature.contains(r#"id="00800""#), "got {feature}"); + } + + // Duration attribute reading: several key spellings, digits only, zero → None. + #[test] + fn playlist_duration_reads_known_attrs() { + assert_eq!( + playlist_duration_secs(r#""#), + Some(7000) + ); + assert_eq!( + playlist_duration_secs(r#""#), + Some(7628) + ); + assert_eq!(playlist_duration_secs(r#""#), None); + assert_eq!(playlist_duration_secs(r#""#), None); + } }