From d7b6aca6e86ed609ac1fde44ee0f21b39dafb243 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:09:35 -0700 Subject: [PATCH] fix(#45): add bdj_feature.rs (unstaged in 0109db9 menu-walk commit) --- src/labels/bdj_feature.rs | 719 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 src/labels/bdj_feature.rs diff --git a/src/labels/bdj_feature.rs b/src/labels/bdj_feature.rs new file mode 100644 index 00000000..9a09ea28 --- /dev/null +++ b/src/labels/bdj_feature.rs @@ -0,0 +1,719 @@ +//! Generic BD-J feature-playlist resolver — the issue #45 "menu-walk" +//! authoritative layer, run when no framework parser surfaced a manifest hint. +//! +//! Two tiers, most reliable first: +//! +//! * **Tier 1 — jar-space resource sweep (High confidence).** Newer discs embed +//! the same `dcx.xml` / `playlists.xml` / `*.properties` manifests that Fox +//! and Paramount ship loose, but INSIDE a `/BDMV/JAR/*.jar` instead. The +//! feature id is still statically recoverable: reuse +//! [`fox::feature_hint`](super::fox::feature_hint) / +//! [`paramount::feature_hint_from_xml`](super::paramount::feature_hint_from_xml). +//! +//! * **Tier 2 — autostart-Xlet locator scan (best-effort, Medium).** Parse the +//! `/BDMV/BDJO/*.bdjo` Application Management Table for the AUTOSTART app's +//! jar(s) ([`crate::bdnav::bdjo`]), harvest `NNNNN.mpls` / `bd://…PLAYLIST:N` +//! String constants from that jar's `.class` constant pools, intersect them +//! with the real `/BDMV/PLAYLIST/*.mpls` set, and disambiguate by parsed +//! duration — the feature is a single candidate that dominates the runner-up +//! and carries multiple audio streams. A hint is emitted ONLY when a single +//! dominant candidate survives; integer-only evidence never emits. +//! +//! Everything failing here (modern-Fox pure-bytecode `StandardMenuXlet` with no +//! usable `.mpls` string, no usable integer constant) is left to the chapter +//! failsafe in `disc::mod`. This module is read-only, bounded, and never panics +//! (the caller also wraps it in `catch_unwind`). + +use super::class_reader::CpInfo; +use super::{FeaturePlaylistHint, fox, jar, paramount}; +use crate::bdnav::bdjo; +use crate::sector::SectorSource; +use crate::udf::UdfFs; +use std::collections::HashSet; + +/// Resolve the disc's feature playlist by walking the BD-J jar space. Returns a +/// hint only when one is unambiguously recoverable; `None` otherwise (defer to +/// the failsafe). +pub(crate) fn resolve(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { + if let Some(hint) = tier1_manifest_sweep(reader, udf).filter(|h| !h.is_empty()) { + tracing::info!(?hint, "bdj menu-walk: Tier-1 embedded manifest hint"); + return Some(hint); + } + if let Some(hint) = tier2_locator_scan(reader, udf).filter(|h| !h.is_empty()) { + tracing::info!(?hint, "bdj menu-walk: Tier-2 autostart-Xlet locator hint"); + return Some(hint); + } + None +} + +// ── Tier 1 ─────────────────────────────────────────────────────────────────── + +// Cap on bytes decoded from a single embedded manifest as UTF-8. Manifests are +// small; a hostile jar entry can't force an unbounded scan. +const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024; + +fn tier1_manifest_sweep(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { + jar::for_each_jar(reader, udf, |_entry, archive| { + jar::try_each_resource(archive, |name, bytes| { + if bytes.len() > MAX_MANIFEST_BYTES { + return None; + } + let text = std::str::from_utf8(bytes).ok()?; + hint_from_manifest(name, text) + }) + }) +} + +// Derive a feature hint from one embedded manifest, dispatched by filename. +fn hint_from_manifest(name: &str, text: &str) -> Option { + let lower = name.to_ascii_lowercase(); + if lower.ends_with("dcx.xml") { + fox::feature_hint(text) + } else if lower.ends_with("playlists.xml") { + paramount::feature_hint_from_xml(text) + } else if lower.ends_with(".properties") || lower.ends_with(".version") { + props_hint(text) + } else { + None + } +} + +// Scan a `key=value` properties/version manifest for a feature-playlist id. A +// key naming both a feature/playlist AND an id (or playlist) whose value carries +// a u16 playlist number yields the canonical `NNNNN.mpls` hint. +fn props_hint(text: &str) -> Option { + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') || line.starts_with('!') { + continue; + } + let Some((key, val)) = line.split_once('=') else { + continue; + }; + let key = key.trim().to_ascii_lowercase(); + let names_playlist = key.contains("feature") || key.contains("playlist"); + let names_id = key.contains("id") || key.contains("playlist"); + if !(names_playlist && names_id) { + continue; + } + if let Some(id) = playlist_id_from_str(val.trim()) { + return Some(hint_for(id)); + } + } + None +} + +// ── Tier 2 ─────────────────────────────────────────────────────────────────── + +// Cap on distinct harvested playlist-id candidates — bounds work on a hostile +// jar. Real menus reference a handful. +const MAX_CANDIDATES: usize = 1024; + +// The feature must run at least this many times longer than the runner-up to be +// unambiguous; a closer field is not resolvable here and defers to the failsafe. +const DOMINANCE_RATIO: f64 = 1.5; + +fn tier2_locator_scan(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { + // 1. AUTOSTART app's jar(s) from the BDJO AMT. Empty → scan every jar. + let targets = autostart_jar_ids(reader, udf); + + // 2. Harvest playlist-id candidates from those jars' `.class` constant pools. + let candidates = harvest_candidates(reader, udf, &targets); + if candidates.is_empty() { + return None; + } + + // 3. Intersect with the playlists that actually exist on the disc. + let real = real_playlist_ids(udf); + let hits: Vec = candidates + .into_iter() + .filter(|id| real.contains(id)) + .collect(); + if hits.is_empty() { + return None; + } + + // 4. Score each survivor by (duration secs, primary-audio count). + let mut scored: Vec<(u16, u64, usize)> = hits + .into_iter() + .filter_map(|id| mpls_stats(reader, udf, id).map(|(secs, aud)| (id, secs, aud))) + .collect(); + if scored.is_empty() { + return None; + } + // Longest first; lowest id breaks a duration tie (deterministic). + scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + + // 5. Emit only a single dominant, multi-audio candidate. + choose_dominant(&scored).map(hint_for) +} + +// The dominant feature id, or None when the field is ambiguous. Requires the top +// candidate to carry multiple audio streams and (when a runner-up exists) to run +// at least DOMINANCE_RATIO times longer than it. +fn choose_dominant(scored: &[(u16, u64, usize)]) -> Option { + let (top_id, top_secs, top_aud) = *scored.first()?; + if top_aud < 2 { + return None; + } + match scored.get(1) { + None => Some(top_id), // single candidate: unambiguous + Some(&(_, runner_secs, _)) => { + (top_secs as f64 >= DOMINANCE_RATIO * runner_secs as f64).then_some(top_id) + } + } +} + +// The jar ids of every AUTOSTART application across all `.bdjo` files. Empty when +// there is no readable BDJO — Tier-2 then scans every jar. +fn autostart_jar_ids(reader: &mut dyn SectorSource, udf: &UdfFs) -> HashSet { + let mut out = HashSet::new(); + let Some(dir) = udf.find_dir("/BDMV/BDJO") else { + return out; + }; + let names: Vec = dir + .entries + .iter() + .filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".bdjo")) + .map(|e| e.name.clone()) + .collect(); + for name in names { + let path = format!("/BDMV/BDJO/{name}"); + let Ok(data) = udf.read_file(reader, &path) else { + continue; + }; + let Some(apps) = bdjo::parse(&data) else { + continue; + }; + for app in apps.iter().filter(|a| a.is_autostart()) { + for id in app.jar_ids() { + out.insert(id); + } + } + } + out +} + +// Harvest playlist-id candidates from `.class` constant pools. When `targets` is +// non-empty, only jars whose id is in it are scanned (the autostart classpath); +// otherwise every jar is scanned. +fn harvest_candidates( + reader: &mut dyn SectorSource, + udf: &UdfFs, + targets: &HashSet, +) -> HashSet { + let mut ids = HashSet::new(); + let _: Option<()> = jar::for_each_jar(reader, udf, |entry_name, archive| { + if !targets.is_empty() && !targets.contains(jar_stem(entry_name)) { + return None; + } + jar::for_each_class(archive, |_class_name, class| { + for (_, cp) in class.constant_pool.iter() { + if let CpInfo::Utf8(s) = cp { + scan_locator_ids(s, &mut ids); + } + } + }); + None + }); + ids +} + +// The jar id of a `/BDMV/JAR/*.jar` entry name ("00000.jar" -> "00000"). +fn jar_stem(entry_name: &str) -> &str { + let lower = entry_name.to_ascii_lowercase(); + if lower.ends_with(".jar") { + &entry_name[..entry_name.len() - 4] + } else { + entry_name + } +} + +// Harvest playlist ids from one string: `NNNNN.mpls` filenames and +// `PLAYLIST:NNNNN` locator constants. Integer-only evidence is intentionally NOT +// harvested here — the design leaves bare integers to the failsafe. +fn scan_locator_ids(s: &str, out: &mut HashSet) { + if out.len() >= MAX_CANDIDATES { + return; + } + let lower = s.to_ascii_lowercase(); + let bytes = s.as_bytes(); // same byte positions as `lower` (ASCII case only) + + // `NNNNN.mpls`: digits immediately preceding a ".mpls". + let mut from = 0; + while let Some(rel) = lower[from..].find(".mpls") { + let at = from + rel; + let mut start = at; + while start > 0 && bytes[start - 1].is_ascii_digit() { + start -= 1; + } + if start < at + && let Ok(id) = s[start..at].parse::() + { + out.insert(id); + if out.len() >= MAX_CANDIDATES { + return; + } + } + from = at + ".mpls".len(); + } + + // `PLAYLIST:NNNNN`: digits immediately following a "playlist:" token. + let mut from = 0; + while let Some(rel) = lower[from..].find("playlist:") { + let at = from + rel + "playlist:".len(); + let mut end = at; + while end < bytes.len() && bytes[end].is_ascii_digit() { + end += 1; + } + if end > at + && let Ok(id) = s[at..end].parse::() + { + out.insert(id); + if out.len() >= MAX_CANDIDATES { + return; + } + } + from = at.max(from + 1); + } +} + +// The playlist ids present as `/BDMV/PLAYLIST/NNNNN.mpls`. +fn real_playlist_ids(udf: &UdfFs) -> HashSet { + let mut out = HashSet::new(); + let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else { + return out; + }; + for e in &dir.entries { + if e.is_dir || !e.name.to_ascii_lowercase().ends_with(".mpls") { + continue; + } + let stem = &e.name[..e.name.len() - ".mpls".len()]; + if let Ok(id) = stem.parse::() { + out.insert(id); + } + } + out +} + +// (duration_secs, primary-audio-stream count) for one playlist id, or None if it +// cannot be read/parsed. +fn mpls_stats(reader: &mut dyn SectorSource, udf: &UdfFs, id: u16) -> Option<(u64, usize)> { + let path = format!("/BDMV/PLAYLIST/{id:05}.mpls"); + let data = udf.read_file(reader, &path).ok()?; + let pl = crate::mpls::parse(&data).ok()?; + let ticks: u64 = pl + .play_items + .iter() + .map(|pi| pi.out_time.saturating_sub(pi.in_time) as u64) + .sum(); + let secs = ticks / 45_000; + // stream_type 2 == primary audio (see mpls::StreamEntry). + let audio = pl.streams.iter().filter(|s| s.stream_type == 2).count(); + Some((secs, audio)) +} + +// ── Shared helpers ─────────────────────────────────────────────────────────── + +fn hint_for(id: u16) -> FeaturePlaylistHint { + FeaturePlaylistHint { + playlist_id: Some(id), + filename: Some(format!("{id:05}.mpls")), + } +} + +// A playlist id from a free-form string value: keep leading digits, parse u16. +fn playlist_id_from_str(v: &str) -> Option { + let digits: String = v.chars().take_while(|c| c.is_ascii_digit()).collect(); + let digits = if digits.is_empty() { + v.chars().filter(|c| c.is_ascii_digit()).collect() + } else { + digits + }; + digits.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::udf::fixture::{DirSpec, FileSpec, MemDisc, build_udf_skeleton, file_with, lay_dir}; + use std::io::{Cursor, Write as _}; + + // ── scan_locator_ids ──────────────────────────────────────────────────── + #[test] + fn scan_finds_mpls_filenames() { + let mut ids = HashSet::new(); + scan_locator_ids("bd://JAR:00000/00800.mpls", &mut ids); + assert!(ids.contains(&800)); + let mut ids = HashSet::new(); + scan_locator_ids("00243.MPLS", &mut ids); + assert!(ids.contains(&243)); + } + + #[test] + fn scan_finds_playlist_locator_constants() { + let mut ids = HashSet::new(); + scan_locator_ids("bd://0.PLAYLIST:00800", &mut ids); + assert!(ids.contains(&800)); + } + + #[test] + fn scan_ignores_bare_integers() { + // An integer-only string carries no ".mpls"/"PLAYLIST:" locator, so it + // must NOT become a candidate — bare integers defer to the failsafe. + let mut ids = HashSet::new(); + scan_locator_ids("800", &mut ids); + scan_locator_ids("theNumberIs 00800 done", &mut ids); + assert!(ids.is_empty()); + } + + // ── choose_dominant ───────────────────────────────────────────────────── + #[test] + fn dominant_single_multi_audio_candidate_wins() { + assert_eq!(choose_dominant(&[(800, 7000, 6)]), Some(800)); + } + + #[test] + fn dominant_requires_multiple_audio() { + // A lone candidate with a single audio stream is not a feature. + assert_eq!(choose_dominant(&[(800, 7000, 1)]), None); + } + + #[test] + fn dominant_needs_1_5x_over_runner_up() { + // 7000 vs 5000 = 1.4x → ambiguous → None. + assert_eq!(choose_dominant(&[(800, 7000, 6), (801, 5000, 6)]), None); + // 7000 vs 4000 = 1.75x → dominant. + assert_eq!( + choose_dominant(&[(800, 7000, 6), (801, 4000, 6)]), + Some(800) + ); + } + + #[test] + fn dominant_two_equal_durations_is_none() { + assert_eq!(choose_dominant(&[(800, 7000, 6), (801, 7000, 6)]), None); + } + + // ── props_hint ────────────────────────────────────────────────────────── + #[test] + fn props_hint_reads_feature_playlist_key() { + let text = "menu.jar=00001\nfeature.playlist.id=00800\nfoo=bar\n"; + let h = props_hint(text).expect("hint"); + assert_eq!(h.playlist_id, Some(800)); + assert_eq!(h.filename.as_deref(), Some("00800.mpls")); + } + + #[test] + fn props_hint_ignores_unrelated_keys() { + assert!(props_hint("version=1\nbuild=42\n").is_none()); + } + + // ── mpls fixture builder (compact single-play-item playlist) ───────────── + fn audio_stream_entry(pid: u16) -> Vec { + let mut out = vec![3u8, 0x01]; // se_len, type = PlayItem clip + out.extend_from_slice(&pid.to_be_bytes()); + // attrs: coding_type(TrueHD) + (5.1<<4|48k) + language + let attrs = [0x83u8, (6 << 4) | 1, b'e', b'n', b'g']; + out.push(attrs.len() as u8); + out.extend_from_slice(&attrs); + out + } + + // A playlist: one play item of `secs` seconds with `n_audio` audio streams. + fn build_mpls(secs: u32, n_audio: u8) -> Vec { + let playlist_start: u32 = 40; + let mut buf = Vec::new(); + buf.extend_from_slice(b"MPLS0200"); + buf.extend_from_slice(&playlist_start.to_be_bytes()); + buf.extend_from_slice(&[0u8; 28]); // mark_start + pad to 40 + + let pl_start = buf.len(); + buf.extend_from_slice(&[0u8; 4]); // 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 + + let mut item = Vec::new(); + item.extend_from_slice(b"00000"); // clip_id + item.extend_from_slice(b"M2TS"); + item.push(0); // reserved + item.push(0); // is_multi_angle + connection_condition + item.push(0); // stc_id + item.extend_from_slice(&0u32.to_be_bytes()); // in_time + item.extend_from_slice(&(secs * 45000).to_be_bytes()); // out_time + item.extend_from_slice(&[0u8; 8]); // UO mask + item.push(0); // misc + item.push(0); // still_mode + item.extend_from_slice(&[0u8; 2]); // still_time + + // STN table + let stn_start = item.len(); + item.extend_from_slice(&[0u8; 2]); // length placeholder + item.extend_from_slice(&[0u8; 2]); // reserved + item.push(0); // n_video + item.push(n_audio); // n_audio + item.extend_from_slice(&[0u8; 6]); // pg/ig/sec.../dv counts + item.extend_from_slice(&[0u8; 4]); // reserved + for i in 0..n_audio { + item.extend_from_slice(&audio_stream_entry(0x1100 + i as u16)); + } + let stn_len = (item.len() - stn_start - 2) as u16; + item[stn_start..stn_start + 2].copy_from_slice(&stn_len.to_be_bytes()); + + 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()); + buf + } + + // Minimal `.class` carrying the given Utf8 constants (no fields/methods). + fn build_class(utf8: &[&str]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0xCAFEBABEu32.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); // minor + out.extend_from_slice(&52u16.to_be_bytes()); // major + out.extend_from_slice(&((utf8.len() + 1) as u16).to_be_bytes()); + for s in utf8 { + out.push(1); + out.extend_from_slice(&(s.len() as u16).to_be_bytes()); + out.extend_from_slice(s.as_bytes()); + } + out.extend_from_slice(&[0u8; 14]); // access/this/super/interfaces/fields/methods/attrs (7 u16) + out + } + + // Zip entries into an in-memory jar (Stored). + 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 + } + + // Lay a BDMV tree with the given PLAYLIST + JAR (+ optional BDJO) files onto + // an in-memory disc and return (disc, udf). LBAs are hand-assigned and never + // overlap. + fn build_disc( + playlists: Vec<(&str, Vec)>, + jars: Vec<(&str, Vec)>, + bdjos: Vec<(&str, Vec)>, + ) -> (MemDisc, crate::udf::UdfFs) { + let mut icb = 100u32; + let mut data = 2000u32; + let mut next = |sz: u64| { + let (i, d) = (icb, data); + icb += 1; + data += (sz / 2048) as u32 + 2; + (i, d) + }; + + let mk = |files: Vec<(&str, Vec)>, next: &mut dyn FnMut(u64) -> (u32, u32)| { + files + .into_iter() + .map(|(name, bytes)| { + let (i, d) = next(bytes.len() as u64); + file_with(name, i, d, bytes, true) + }) + .collect::>() + }; + + let playlist_dir = DirSpec { + name: "PLAYLIST".to_string(), + icb_lba: 50, + dir_data_lba: 51, + files: mk(playlists, &mut next), + subdirs: vec![], + }; + let jar_dir = DirSpec { + name: "JAR".to_string(), + icb_lba: 52, + dir_data_lba: 53, + files: mk(jars, &mut next), + subdirs: vec![], + }; + let mut subdirs = vec![playlist_dir, jar_dir]; + if !bdjos.is_empty() { + subdirs.push(DirSpec { + name: "BDJO".to_string(), + icb_lba: 54, + dir_data_lba: 55, + files: mk(bdjos, &mut next), + subdirs: vec![], + }); + } + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 12, + dir_data_lba: 13, + files: vec![], + subdirs, + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: vec![], + 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"); + (disc, udf) + } + + // ── Tier 1 integration ────────────────────────────────────────────────── + #[test] + fn tier1_resolves_from_embedded_playlists_xml() { + let xml = br#" + + + "#; + let jar = build_jar(&[ + ("com/studio/Menu.class", build_class(&["unrelated"])), + ("00000/playlists.xml", xml.to_vec()), + ]); + let (mut disc, udf) = build_disc(vec![], vec![("00000.jar", jar)], vec![]); + let hint = resolve(&mut disc, &udf).expect("Tier-1 hint"); + assert_eq!(hint.playlist_id, Some(800)); + } + + // ── Tier 2 integration ────────────────────────────────────────────────── + #[test] + fn tier2_resolves_dominant_mpls_locator() { + // Autostart jar's class references "00800.mpls"; the disc has a long + // 00800 (multi-audio) and a short 00801. No manifest → Tier-2 resolves. + let class = build_class(&["bd://JAR:00000/00800.mpls", "some/other/String"]); + let jar = build_jar(&[("com/studio/MainXlet.class", class)]); + let (mut disc, udf) = build_disc( + vec![ + ("00800.mpls", build_mpls(7000, 6)), + ("00801.mpls", build_mpls(120, 2)), + ], + vec![("00000.jar", jar)], + vec![], // no BDJO → scan all jars + ); + let hint = resolve(&mut disc, &udf).expect("Tier-2 hint"); + assert_eq!(hint.playlist_id, Some(800)); + assert_eq!(hint.filename.as_deref(), Some("00800.mpls")); + } + + #[test] + fn tier2_abstains_when_candidate_not_a_real_playlist() { + // The class points at 09999.mpls, which is not on the disc → no hit. + let class = build_class(&["09999.mpls"]); + let jar = build_jar(&[("com/studio/MainXlet.class", class)]); + let (mut disc, udf) = build_disc( + vec![("00800.mpls", build_mpls(7000, 6))], + vec![("00000.jar", jar)], + vec![], + ); + assert!(resolve(&mut disc, &udf).is_none()); + } + + #[test] + fn tier2_abstains_on_integer_only_bytecode() { + // The autostart class carries only a bare integer constant (800) and no + // .mpls / PLAYLIST: locator string — modern-Fox StandardMenuXlet shape. + // No candidate is harvested → defer to the chapter failsafe. + let mut class = Vec::new(); + class.extend_from_slice(&0xCAFEBABEu32.to_be_bytes()); + class.extend_from_slice(&0u16.to_be_bytes()); + class.extend_from_slice(&52u16.to_be_bytes()); + class.extend_from_slice(&2u16.to_be_bytes()); // cp_count = 2 (one entry) + class.push(3); // CONSTANT_Integer + class.extend_from_slice(&800u32.to_be_bytes()); + class.extend_from_slice(&[0u8; 14]); + let jar = build_jar(&[("com/foxbd/StandardMenuXlet.class", class)]); + let (mut disc, udf) = build_disc( + vec![("00800.mpls", build_mpls(7000, 6))], + vec![("00000.jar", jar)], + vec![], + ); + assert!(resolve(&mut disc, &udf).is_none()); + } + + #[test] + fn tier2_uses_bdjo_autostart_jar_targeting() { + // Two jars; only the autostart one (00000) names the feature. A decoy jar + // (00009) points at a different real playlist. The BDJO AMT pins 00000 as + // autostart, so only its candidate (00800) is harvested. + let auto_class = build_class(&["00800.mpls"]); + let decoy_class = build_class(&["00801.mpls"]); + let auto_jar = build_jar(&[("com/studio/MainXlet.class", auto_class)]); + let decoy_jar = build_jar(&[("com/studio/Decoy.class", decoy_class)]); + + let bdjo = bdjo_with_autostart("00000"); + let (mut disc, udf) = build_disc( + vec![ + ("00800.mpls", build_mpls(7000, 6)), + // 00801 is LONGER, so if the decoy jar were scanned it would win. + ("00801.mpls", build_mpls(9000, 6)), + ], + vec![("00000.jar", auto_jar), ("00009.jar", decoy_jar)], + vec![("00000.bdjo", bdjo)], + ); + let hint = resolve(&mut disc, &udf).expect("Tier-2 hint via BDJO targeting"); + assert_eq!( + hint.playlist_id, + Some(800), + "only the autostart jar's candidate should be considered" + ); + } + + // Build a minimal single-app BDJO whose AUTOSTART app has the given base_dir. + fn bdjo_with_autostart(base_dir: &str) -> Vec { + fn 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); + } + } + let mut b = Vec::new(); + b.extend_from_slice(b"BDJO"); + b.extend_from_slice(b"0200"); + b.extend_from_slice(&[0u8; 40]); // section-address table + b.extend_from_slice(&[0u8; 4]); // TerminalInfo length + b.extend_from_slice(&[0u8; 5]); // default_font + b.extend_from_slice(&[0u8; 5]); // havi/masks + padding (40 bits) + b.extend_from_slice(&[0u8; 4]); // AppCacheInfo length + b.push(0); // num_item + b.push(0); // padding + b.extend_from_slice(&[0u8; 4]); // AccessiblePlaylists length + b.extend_from_slice(&[0u8; 4]); // num_pl(11)+flags(2)+pad(19) = 0 + b.extend_from_slice(&[0u8; 4]); // AMT length + b.push(1); // num_app + b.push(0); // padding + // App record + b.push(1); // control_code = AUTOSTART + b.push(0); // type(4)+reserved(4) + b.extend_from_slice(&[0u8; 4]); // org_id + b.extend_from_slice(&[0u8; 2]); // app_id + b.extend_from_slice(&[0u8; 10]); // descriptor tag+length + b.push(0); // num_profile(4)+pad + b.push(0); + b.push(0); // priority + b.push(0); // binding/visibility/reserved + b.extend_from_slice(&[0u8; 2]); // app_name data_length = 0 + app_string(&mut b, ""); // icon_locator + b.extend_from_slice(&[0u8; 2]); // icon_flags + app_string(&mut b, base_dir); // base_dir + app_string(&mut b, ""); // classpath_extension + app_string(&mut b, "com.studio.MainXlet"); // initial_class + b.push(0); // params data_length = 0 + b.push(0); // word-align pad + b + } +}