mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
@@ -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<FeaturePlaylistHint> {
|
||||
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<FeaturePlaylistHint> {
|
||||
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<FeaturePlaylistHint> {
|
||||
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<FeaturePlaylistHint> {
|
||||
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<FeaturePlaylistHint> {
|
||||
// 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<u16> = 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<u16> {
|
||||
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<String> {
|
||||
let mut out = HashSet::new();
|
||||
let Some(dir) = udf.find_dir("/BDMV/BDJO") else {
|
||||
return out;
|
||||
};
|
||||
let names: Vec<String> = 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<String>,
|
||||
) -> HashSet<u16> {
|
||||
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<u16>) {
|
||||
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::<u16>()
|
||||
{
|
||||
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::<u16>()
|
||||
{
|
||||
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<u16> {
|
||||
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::<u16>() {
|
||||
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<u16> {
|
||||
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::<u16>().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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<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
|
||||
}
|
||||
|
||||
// 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<u8>)>,
|
||||
jars: Vec<(&str, Vec<u8>)>,
|
||||
bdjos: Vec<(&str, Vec<u8>)>,
|
||||
) -> (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<u8>)>, 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::<Vec<FileSpec>>()
|
||||
};
|
||||
|
||||
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#"<playlists>
|
||||
<playlist name="Feature" id="00800" aud="eng,fra,spa" duration="7000" />
|
||||
<playlist name="Preview" id="00050" aud="eng" duration="120" />
|
||||
</playlists>"#;
|
||||
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<u8> {
|
||||
fn 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);
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user