Establishes the shared infrastructure layer for label parsers so that
Java-touching parsers (dbp, deluxe) don't reimplement jar walking and
all parsers route language/purpose/qualifier classification through
one source of truth instead of N hand-rolls.
New modules:
vocab.rs expanded from 27 -> ~370 lines
+ lang(text) -> Option<&'static str> (English/multi-word
-> ISO 639-2; ~45
languages, compound
phrases like
'Brazilian Portuguese'
and 'Castilian Spanish')
+ purpose(text) -> LabelPurpose (Commentary,
Descriptive, Score,
Ime; word-boundary
matched)
+ qualifier(text) -> LabelQualifier (SDH, Forced,
DescriptiveService)
+ has_word internal primitive — enforces word-boundary
matching so 'Commenter' no longer matches 'commentary' and
'engineering' no longer matches 'english'. Existing parsers
used .contains() and got lucky on the corpus; vocab now
guarantees the boundary in one place. 20+ unit tests.
text.rs NEW (~85 lines)
+ extract_ascii_strings(data, min_len) — promoted from two
near-duplicate copies (pixelogic min=4, dbp min=5);
threshold passed in. 7 unit tests including
trailing-without-terminator + high-bit-byte handling.
jar.rs NEW (~120 lines)
+ for_each_jar(reader, udf, fn) — walk every top-level
.jar under /BDMV/JAR/,
yield to callback.
+ has_path_prefix(archive, prefix) — cheap 'is this MY
framework's jar?' check
via central-dir filenames.
+ for_each_class(archive, fn) — parse every .class entry
through class_reader,
yield (name, &ClassFile).
+ try_each_class(archive, fn) — same with early-return on
first Some(R) match.
Refactored:
dbp.rs v2 on the new platform:
- dropped extract_printable raw byte scan
- dropped its own English -> ISO 639-2 map
- dropped its own parse_attributes hand-roll
+ iterates CpInfo::Utf8 via class_reader (structurally clean,
no false-positive risk from method bytecode bytes)
+ routes language/purpose/qualifier through vocab
All 7 prior dbp tests still pass; +2 new ones cover
vocab routing.
dead-code allows on text.rs (extract_ascii_strings) and jar.rs
(try_each_class) come off when pixelogic and deluxe land — they're
staged for next steps.
Precommit green (cargo +1.86 fmt + clippy + test).
258 lines
9.0 KiB
Rust
258 lines
9.0 KiB
Rust
//! "dbp" framework — Magnolia Pictures BD-J authoring shop (per
|
|
//! `bd-live.magpictures.com` referenced in the disc's
|
|
//! `com/dbp/bluray.MenuXlet.perm`). Detected on UHD discs whose
|
|
//! `/BDMV/JAR/<x>.jar` (top-level, not in a subdir) contains
|
|
//! `com/dbp/` package paths.
|
|
//!
|
|
//! Stream labels live as plain ASCII strings inside compiled `.class`
|
|
//! files in the jar — a quirk of the menu-rendering layer encoding
|
|
//! its TextField positions and content as constant strings the
|
|
//! Java compiler retained in the class string pool. Format observed
|
|
//! in the corpus (Civil War UHD, 2024):
|
|
//!
|
|
//! ```text
|
|
//! LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,...
|
|
//! RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,...
|
|
//! HTextField,Subtitle1,English SDH,Fontstrip_Composite,...
|
|
//! ATextField,Subtitle0,None,Fontstrip_Composite,...
|
|
//! ```
|
|
//!
|
|
//! The single uppercase letter before `TextField` is string-pool
|
|
//! prefix noise — the parser anchors on `TextField,` regardless of
|
|
//! what precedes it. `Subtitle0` is the disable-subtitles menu
|
|
//! button and is skipped (not a real subtitle stream).
|
|
//!
|
|
//! ## Implementation
|
|
//!
|
|
//! v2 (2026-05-10): rewritten on top of [`super::class_reader`] —
|
|
//! iterates `CpInfo::Utf8` constant-pool entries instead of raw byte
|
|
//! scanning each class file. Equivalent label coverage (the literal
|
|
//! `TextField,...` strings live in the CP as Utf8 entries), but
|
|
//! structurally cleaner: no false-positive risk from method bytecode
|
|
//! or attribute names happening to contain `TextField,`. Language /
|
|
//! purpose / qualifier classification moved to [`super::vocab`] so all
|
|
//! Java-parser families share one source of truth.
|
|
|
|
use super::class_reader::CpInfo;
|
|
use super::{StreamLabel, StreamLabelType, jar, vocab};
|
|
use crate::sector::SectorReader;
|
|
use crate::udf::UdfFs;
|
|
use std::collections::BTreeMap;
|
|
|
|
/// dbp detect can't peek inside a jar without a SectorReader (the
|
|
/// trait function only takes `&UdfFs`), so we trigger on the cheap
|
|
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
|
|
/// BD-J disc, but parse() does the real `com/dbp/` check and
|
|
/// returns None on a mismatch — so this parser only ever consumes
|
|
/// time on discs that fell through every earlier parser.
|
|
pub fn detect(udf: &UdfFs) -> bool {
|
|
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
|
return false;
|
|
};
|
|
jar_dir
|
|
.entries
|
|
.iter()
|
|
.any(|e| !e.is_dir && e.name.to_lowercase().ends_with(".jar"))
|
|
}
|
|
|
|
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
|
jar::for_each_jar(reader, udf, |_entry_name, archive| {
|
|
if !jar::has_path_prefix(archive, "com/dbp/") {
|
|
return None;
|
|
}
|
|
let labels = scan_jar(archive);
|
|
if labels.is_empty() {
|
|
None
|
|
} else {
|
|
Some(labels)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn scan_jar(archive: &mut jar::Jar) -> Vec<StreamLabel> {
|
|
// BTreeMap so we keep the highest-numbered (last-written) label
|
|
// for each stream slot deterministic across runs. The same
|
|
// TextField,Audio1,... string can appear in multiple classes
|
|
// (button-state variants, localization fallbacks). Last write
|
|
// wins — they should all agree, but the structure is defensive.
|
|
let mut audios: BTreeMap<u16, String> = BTreeMap::new();
|
|
let mut subs: BTreeMap<u16, String> = BTreeMap::new();
|
|
|
|
jar::for_each_class(archive, |_class_name, class| {
|
|
for (_idx, cp) in class.constant_pool.iter() {
|
|
if let CpInfo::Utf8(s) = cp {
|
|
collect_textfield(s, &mut audios, &mut subs);
|
|
}
|
|
}
|
|
});
|
|
|
|
let mut out = Vec::new();
|
|
for (num, label) in audios {
|
|
out.push(make_label(num, label, StreamLabelType::Audio));
|
|
}
|
|
for (num, label) in subs {
|
|
out.push(make_label(num, label, StreamLabelType::Subtitle));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn collect_textfield(
|
|
s: &str,
|
|
audios: &mut BTreeMap<u16, String>,
|
|
subs: &mut BTreeMap<u16, String>,
|
|
) {
|
|
// Anchor on "TextField," — the prefix character before it varies
|
|
// (string-pool ordering inside compiled Java) and is irrelevant.
|
|
let Some(idx) = s.find("TextField,") else {
|
|
return;
|
|
};
|
|
let after = &s[idx + "TextField,".len()..];
|
|
let mut parts = after.splitn(3, ',');
|
|
let kind_n = parts.next().unwrap_or("").trim();
|
|
let label = parts.next().unwrap_or("").trim();
|
|
if label.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(rest) = kind_n.strip_prefix("Audio") {
|
|
if let Ok(n) = rest.parse::<u16>() {
|
|
audios.insert(n, label.to_string());
|
|
}
|
|
} else if let Some(rest) = kind_n.strip_prefix("Subtitle") {
|
|
if let Ok(n) = rest.parse::<u16>() {
|
|
// Subtitle0 is conventionally the "None / Off" disable
|
|
// button, not an actual subtitle stream.
|
|
if n > 0 {
|
|
subs.insert(n, label.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLabel {
|
|
let language = vocab::lang(&label).unwrap_or_default().to_string();
|
|
let qualifier = vocab::qualifier(&label);
|
|
let purpose = vocab::purpose(&label);
|
|
StreamLabel {
|
|
stream_number: num,
|
|
stream_type,
|
|
language,
|
|
name: label,
|
|
purpose,
|
|
qualifier,
|
|
codec_hint: String::new(),
|
|
variant: String::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::{LabelPurpose, LabelQualifier};
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn collect_extracts_audio_and_subtitle_indices() {
|
|
let mut audios = BTreeMap::new();
|
|
let mut subs = BTreeMap::new();
|
|
let lines = [
|
|
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763,275,25,left",
|
|
"RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,296,803,275,25,left",
|
|
"RTextField,Audio3,Spanish 5.1 Dolby Digital,Fontstrip_Composite,296,843,275,25,left",
|
|
"ATextField,Subtitle0,None,Fontstrip_Composite,1312,843,275,25,left",
|
|
"HTextField,Subtitle1,English SDH,Fontstrip_Composite,1312,763,275,25,left",
|
|
"DTextField,Subtitle2,Spanish,Fontstrip_Composite,1312,803,275,25,left",
|
|
];
|
|
for s in &lines {
|
|
collect_textfield(s, &mut audios, &mut subs);
|
|
}
|
|
assert_eq!(audios.len(), 3);
|
|
assert_eq!(audios[&1], "English Dolby Atmos");
|
|
assert_eq!(audios[&2], "English Descriptive Audio");
|
|
assert_eq!(audios[&3], "Spanish 5.1 Dolby Digital");
|
|
// Subtitle0 ("None") is skipped — disable button, not a stream.
|
|
assert_eq!(subs.len(), 2);
|
|
assert_eq!(subs[&1], "English SDH");
|
|
assert_eq!(subs[&2], "Spanish");
|
|
}
|
|
|
|
#[test]
|
|
fn collect_ignores_non_textfield_strings() {
|
|
let mut audios = BTreeMap::new();
|
|
let mut subs = BTreeMap::new();
|
|
for s in [
|
|
"GraphicButton,SU_Audio",
|
|
"AudioMenu",
|
|
"CommentaryMenuAlternateScenes",
|
|
"PrimaryAudioControl",
|
|
] {
|
|
collect_textfield(s, &mut audios, &mut subs);
|
|
}
|
|
assert!(audios.is_empty());
|
|
assert!(subs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn make_label_routes_via_vocab() {
|
|
let l = make_label(1, "English SDH".to_string(), StreamLabelType::Subtitle);
|
|
assert_eq!(l.language, "eng");
|
|
assert_eq!(l.qualifier, LabelQualifier::Sdh);
|
|
assert_eq!(l.purpose, LabelPurpose::Normal);
|
|
}
|
|
|
|
#[test]
|
|
fn make_label_descriptive_audio() {
|
|
let l = make_label(
|
|
2,
|
|
"English Descriptive Audio".to_string(),
|
|
StreamLabelType::Audio,
|
|
);
|
|
assert_eq!(l.language, "eng");
|
|
assert_eq!(l.purpose, LabelPurpose::Descriptive);
|
|
}
|
|
|
|
#[test]
|
|
fn make_label_commentary() {
|
|
let l = make_label(
|
|
3,
|
|
"English Director's Commentary".to_string(),
|
|
StreamLabelType::Audio,
|
|
);
|
|
assert_eq!(l.language, "eng");
|
|
assert_eq!(l.purpose, LabelPurpose::Commentary);
|
|
}
|
|
|
|
#[test]
|
|
fn make_label_compound_languages() {
|
|
assert_eq!(
|
|
make_label(1, "Brazilian Portuguese 5.1".into(), StreamLabelType::Audio).language,
|
|
"por"
|
|
);
|
|
assert_eq!(
|
|
make_label(1, "Castilian Spanish".into(), StreamLabelType::Audio).language,
|
|
"spa"
|
|
);
|
|
assert_eq!(
|
|
make_label(
|
|
1,
|
|
"Canadian French Dolby Digital".into(),
|
|
StreamLabelType::Audio
|
|
)
|
|
.language,
|
|
"fra"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn make_label_unknown_language_is_empty() {
|
|
// vocab::lang returns None — make_label converts to "".
|
|
let l = make_label(1, "Klingon Dolby Atmos".into(), StreamLabelType::Audio);
|
|
assert_eq!(l.language, "");
|
|
}
|
|
|
|
#[test]
|
|
fn make_label_rnib_descriptive_service() {
|
|
let l = make_label(1, "English RNIB".into(), StreamLabelType::Subtitle);
|
|
assert_eq!(l.language, "eng");
|
|
assert_eq!(l.qualifier, LabelQualifier::DescriptiveService);
|
|
}
|
|
}
|