Merge #45 menu-walk: BD-J feature resolution (BDJO AMT + jar/manifest + Tier-2 locator) above the chapter failsafe

This commit is contained in:
Matthew Jackson
2026-09-20 20:08:39 -07:00
6 changed files with 861 additions and 38 deletions
+432
View File
@@ -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/<base_directory>.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/<id>.jar`.
pub fn jar_ids(&self) -> Vec<String> {
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<Vec<BdjoApp>> {
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<BdjoApp> {
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<String> {
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<u64> {
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<u8>, 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<u8> {
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"]);
}
}
+1
View File
@@ -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;
+65 -12
View File
@@ -155,12 +155,25 @@ pub(crate) fn labels_from_dcx(text: &str) -> Vec<StreamLabel> {
labels
}
// Pick the richest `<playlist name="feature">` 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 `<playlist>` element, digits only, when stated.
fn playlist_duration_secs(element: &str) -> Option<u64> {
let v = xml::attr(element, "durs")?;
let digits: String = v.chars().filter(|c| c.is_ascii_digit()).collect();
digits.parse::<u64>().ok().filter(|&d| d > 0)
}
// Pick the richest `<playlist name="feature">` (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#"<dcx><disc>
<playlist id="00800" lang="eng" name="feature" durs="6000">
<audio id="01" lang="eng" type="feature"/>
<subtitle id="01" lang="eng" type="feature"/>
</playlist>
<playlist id="00900" lang="eng" name="feature" durs="8000">
<audio id="01" lang="eng" type="feature"/>
<subtitle id="01" lang="eng" type="feature"/>
</playlist>
</disc></dcx>"#;
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#"<dcx><disc>
<playlist id="00050" lang="eng" name="feature" durs="4">
<audio id="01" lang="eng" type="feature"/>
<audio id="02" lang="fra" type="feature"/>
<subtitle id="01" lang="eng" type="feature"/>
</playlist>
<playlist id="00800" lang="eng" name="feature" durs="7628">
<audio id="01" lang="eng" type="feature"/>
</playlist>
</disc></dcx>"#;
// 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]
+75
View File
@@ -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<u8>` 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/<id>/` files. Directory entries and `.class` files are skipped;
/// unreadable entries are silently ignored (robustness over completeness).
pub fn try_each_resource<R, F>(archive: &mut Jar, mut f: F) -> Option<R>
where
F: FnMut(&str, &[u8]) -> Option<R>,
{
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"<dcx><disc/></dcx>";
// 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<String> = Vec::new();
let found: Option<Vec<u8>> = 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"
);
}
}
+99 -2
View File
@@ -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<FeaturePlaylistHint> {
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<FeaturePlaylistHint>,
) -> Option<FeaturePlaylistHint> {
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<u8>)]) -> Vec<u8> {
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#"<playlists>
<playlist name="Feature" id="00800" aud="eng,fra,spa" duration="7000" />
<playlist name="Preview" id="00050" aud="eng" duration="90" />
</playlists>"#;
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<DiscTitle> = 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"));
}
}
+189 -24
View File
@@ -212,37 +212,137 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
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<super::FeaturePlaylistHint> {
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 `<playlist>` 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<u64> {
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::<u64>()
&& 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<u64>,
}
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<String> {
let mut best: Option<String> = None;
let mut best_aud_count = 0;
let mut exact: Vec<Candidate> = Vec::new();
let mut feature_like: Vec<Candidate> = Vec::new();
let mut all: Vec<Candidate> = 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())
}
best
_ => {}
}
all.push(make());
}
// 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<String> {
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#"
<playlist name="_Start_Angle" id="00243" aud="eng,fra,spa" duration="2" />
<playlist name="_Feature" id="00800" aud="eng,fra,spa" duration="7000" />
<playlist name="Feature_A" id="00801" aud="eng,fra,spa" duration="7000" />
"#;
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#"
<playlist name="_Feature" id="00800" aud="eng,fra" duration="6000" />
<playlist name="Feature_B" id="00802" aud="eng,fra" duration="8000" />
"#;
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#"
<playlist name="Feature_Trailer" id="00050" aud="eng,fra,spa" duration="30" />
<playlist name="MainMovie" id="00800" aud="eng,fra,spa" duration="7000" />
"#;
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#"<playlist duration="7000" />"#),
Some(7000)
);
assert_eq!(
playlist_duration_secs(r#"<playlist durs="7628" />"#),
Some(7628)
);
assert_eq!(playlist_duration_secs(r#"<playlist dur="0" />"#), None);
assert_eq!(playlist_duration_secs(r#"<playlist name="x" />"#), None);
}
}