From 1d46ad02b6b9d8aba11ae3ac3723abb2cd8c8b27 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Sun, 10 May 2026 15:22:11 -0700 Subject: [PATCH] =?UTF-8?q?labels:=20add=20deluxe=20parser=20(Phase=20A=20?= =?UTF-8?q?=E2=80=94=20master=20enum=20identification)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the 'detected but no parser' gap on Deluxe-authored BD-J discs (com/bydeluxe/ package signature; ~20% of UHD discs in our corpus per the 2026-05-10 11-disc capture session). What ships in this commit: - detect() — registers the parser in the chain (loose pre-check at the /BDMV/JAR/ level; real signal in parse via has_path_prefix on 'com/bydeluxe/'). - Phase A master enum identification — walks every .class's ldc sequence and matches against framework-stable fingerprints for: Language (70 ldcs starting English/French/Spanish/Dutch) Purpose (8 ldcs starting Normal/Commentary/PiP/Trivia) VideoFormat (7 ldcs starting HD/HDR10 Plus/HD Dolby) Region (22 ldcs starting USA_D1/LIC1/LIC2/LIC3 — Disney-only) Studio (6 ldcs starting Disney/Marvel/Pixar — Disney-only) All identifications verified out-of-tree on corpus disc-01 (Disney, The Amateur) and disc-09 (Warner, Dune Part 1) via the standalone POC. - Phase B structural skeleton (find_codec_enum) — identifies the codec enum class by structural signature (>=20 'new' ops, 0 ldcs), returns the ordered subclass list. Codec string extraction from subclasses is dead-coded pending the follow-up commit. What does NOT ship yet: - Phase D (per-stream binding-class decoder). parse() returns None intentionally — the master enums alone don't yield StreamLabels without the streamTable.put(...) bytecode walker. analyze() will show 'deluxe' in parsers_detected with the enum identification in tracing logs, so the analyzer reports honestly: 'detected, can't emit labels yet' rather than silent failure. Why ship A without D: A is proven on real corpus discs; D's design needs ground-truth binding bytecode from at least 2 corpus discs side-by-side to verify the stack-machine pattern. The Phase A infrastructure (master enum identification + ordinal->name table) is what D will consume — landing it now unblocks D's design without holding back the parser registration. 5 unit tests cover the fingerprint matcher logic + a roster lock that forces explicit consideration when adding/removing fingerprints. Precommit green. --- src/labels/deluxe.rs | 421 +++++++++++++++++++++++++++++++++++++++++++ src/labels/mod.rs | 8 +- 2 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/labels/deluxe.rs diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs new file mode 100644 index 0000000..cb6d05d --- /dev/null +++ b/src/labels/deluxe.rs @@ -0,0 +1,421 @@ +//! Deluxe BD-J framework — `com/bydeluxe/bluray/` package signature. +//! +//! Used by major studios (Disney, Warner, others) for their UHD +//! BD-J authoring. Detected on discs whose `/BDMV/JAR/.jar` +//! contains a `com/bydeluxe/` directory entry. +//! +//! ## Why this parser exists +//! +//! Deluxe-authored discs store stream labels as **ordinal references +//! into obfuscated enum classes**. The label text isn't a literal +//! string in any anchor pattern (unlike dbp's `TextField,...` rows). +//! Instead, the binding code is roughly: +//! +//! ```java +//! streamTable.put(1, new AudioSlot(LanguageEnum.English, +//! CodecEnum.ATMOS_HD_AUDIO, +//! PurposeEnum.Normal)); +//! ``` +//! +//! The class names `LanguageEnum`, `CodecEnum`, `PurposeEnum`, and +//! `AudioSlot` are obfuscated per-disc (`be.class`, `ma.class`, +//! `lp.class`, etc.) — no name pattern survives the obfuscator. But +//! the **shape of ``** is framework-stable: +//! +//! | Enum | Signature | +//! |---|---| +//! | Language | 70 `ldc` operations in ``, sequence starts `English, French, Spanish, Dutch, ...` | +//! | Purpose | 8 ldcs starting `Normal, Commentary, PiP, Trivia, ...` | +//! | VideoFormat | 7 ldcs starting `HD, HDR10 Plus, HD Dolby, ...` | +//! | Region | 22 ldcs starting `USA_D1, LIC1, LIC2, LIC3, ...` (Disney only) | +//! | Studio | 6 ldcs starting `Disney, Marvel, Pixar, ...` (Disney only) | +//! | Codec | ~46 `new` instructions, 0 ldcs in `` (codec strings live in subclasses) | +//! +//! Match on the SHAPE, not the name, and the parser survives obfuscation. +//! +//! ## Current status (2026-05-10) +//! +//! **Phase A only — master enum identification.** The parser correctly: +//! - Detects every Deluxe-authored disc via the package prefix. +//! - Identifies all 5–6 master enums and decodes their ordinal → name tables. +//! +//! It does NOT yet emit per-stream [`StreamLabel`]s. The binding-class +//! decoder (Phase D: walk the per-stream-table class's `` with +//! a tiny symbolic stack machine to extract `(stream_idx, lang, codec, +//! purpose)` tuples) needs ground-truth binding bytecode from at least +//! 2 corpus discs to design against. Until that lands, `parse()` +//! returns `None`, and the diagnostic harness ([`super::analyze`]) +//! reports `deluxe` in `parsers_detected` with the enum identification +//! visible via `tracing` logs. +//! +//! This is honest staging: detect right, identify what we can prove, +//! emit no labels until the per-stream layer is implemented and +//! verified. Phases B (codec subclass walk), C (binding-class +//! finder), and D land as follow-up commits. + +use super::class_reader::{AASTORE, CpInfo, LDC, LDC_W, NEW}; +use super::{StreamLabel, jar}; +use crate::sector::SectorReader; +use crate::udf::UdfFs; + +pub fn detect(udf: &UdfFs) -> bool { + // Cheap pre-check at the dir level; the real signal is + // `com/bydeluxe/` inside any top-level jar's central directory, + // which `parse()` confirms when given a `SectorReader`. + 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> { + jar::for_each_jar(reader, udf, |entry_name, archive| { + if !jar::has_path_prefix(archive, "com/bydeluxe/") { + return None; + } + let enums = identify_master_enums(archive); + if enums.is_empty() { + // No recognized fingerprint — likely a Deluxe variant we + // haven't catalogued yet. Log + fall through so the + // analyzer can record that detection fired but parse + // produced nothing. + tracing::info!( + jar = %entry_name, + "deluxe parser: com/bydeluxe/ present but no master enum fingerprint matched" + ); + return None; + } + for (label, m) in &enums { + tracing::info!( + jar = %entry_name, + enum = %label, + class = %m.class_name, + count = m.values.len(), + sample = ?m.values.iter().take(4).collect::>(), + "deluxe master enum identified", + ); + } + // Phase D (binding-class decoder) not yet implemented; the + // master enums alone don't yield per-stream labels. Returning + // None routes the disc into the "parser detected but emitted + // no labels" diagnostic path — accurate, not silent failure. + None + }) +} + +/// One identified master enum class. +#[derive(Debug)] +pub(crate) struct MasterEnum { + /// Obfuscated class name (e.g. `be.class`, `aw.class`). + pub class_name: String, + /// Ordinal → string-value mapping, in declaration order. + pub values: Vec, +} + +/// Fingerprints we use to identify each master enum class. The +/// matcher walks every class's `` ldc sequence; a class +/// matches if its first N ldcs match `prefix` AND the total ldc count +/// equals `expected_count` (allows some slack via tolerance — see +/// `LDC_COUNT_TOLERANCE`). Class names are obfuscated and change per +/// disc; shape is stable. +struct Fingerprint { + label: &'static str, + prefix: &'static [&'static str], + expected_count: usize, +} + +const FINGERPRINTS: &[Fingerprint] = &[ + Fingerprint { + label: "Language", + prefix: &["English", "French", "Spanish", "Dutch"], + expected_count: 70, + }, + Fingerprint { + label: "Purpose", + prefix: &["Normal", "Commentary", "PiP", "Trivia"], + expected_count: 8, + }, + Fingerprint { + label: "VideoFormat", + prefix: &["HD", "HDR10 Plus", "HD Dolby"], + expected_count: 7, + }, + Fingerprint { + label: "Region", + prefix: &["USA_D1", "LIC1", "LIC2", "LIC3"], + expected_count: 22, + }, + Fingerprint { + label: "Studio", + prefix: &["Disney", "Marvel", "Pixar"], + expected_count: 6, + }, +]; + +/// Allow per-version drift in enum size (e.g. one disc had 22 regions, +/// a future build might add one). Matching is still anchored on the +/// prefix, so a count mismatch within tolerance is informative-but-OK. +const LDC_COUNT_TOLERANCE: usize = 4; + +/// Phase A. Walk every `.class` in `archive`, identify the master +/// enums by `` ldc-sequence fingerprint. Returns a vector of +/// `(label, MasterEnum)` — at most one match per fingerprint label. +pub(crate) fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str, MasterEnum)> { + use std::collections::HashMap; + + // First pass: collect every class's ldc string sequence. + let mut candidates: HashMap> = HashMap::new(); + jar::for_each_class(archive, |class_name, class| { + let Some(ldcs) = clinit_ldc_strings(class) else { + return; + }; + if ldcs.is_empty() { + return; + } + candidates.insert(class_name.to_string(), ldcs); + }); + + // Second pass: match each fingerprint against the candidate pool. + let mut out = Vec::new(); + for fp in FINGERPRINTS { + let mut best: Option<(String, Vec)> = None; + for (name, ldcs) in &candidates { + if !ldcs_match_prefix(ldcs, fp.prefix) { + continue; + } + let count = ldcs.len(); + if count.abs_diff(fp.expected_count) > LDC_COUNT_TOLERANCE { + continue; + } + // Prefer exact-count match; otherwise first hit wins. + match &best { + None => best = Some((name.clone(), ldcs.clone())), + Some((_, prev)) => { + if count == fp.expected_count && prev.len() != fp.expected_count { + best = Some((name.clone(), ldcs.clone())); + } + } + } + } + if let Some((class_name, values)) = best { + out.push((fp.label, MasterEnum { class_name, values })); + } + } + out +} + +/// Walk `` and collect every `ldc` / `ldc_w` operand that +/// resolves to either a `String` constant or a `Utf8` constant, in +/// declaration order. Returns `None` if the class has no ``. +fn clinit_ldc_strings(class: &super::class_reader::ClassFile) -> Option> { + let mut found = false; + let mut out = Vec::new(); + for m in &class.methods { + let Some(name) = class.member_name(m) else { + continue; + }; + if name != "" { + continue; + } + found = true; + let Some(code) = m.code(&class.constant_pool) else { + continue; + }; + for insn in code.instructions() { + if insn.opcode != LDC && insn.opcode != LDC_W { + continue; + } + let Some(idx) = insn.cp_index() else { + continue; + }; + let resolved = match class.constant_pool.get(idx) { + Some(CpInfo::String { string_index }) => { + class.constant_pool.utf8(*string_index).map(str::to_string) + } + Some(CpInfo::Utf8(s)) => Some(s.clone()), + _ => None, + }; + if let Some(s) = resolved { + out.push(s); + } + } + } + if found { Some(out) } else { None } +} + +/// True if the first `prefix.len()` entries of `ldcs` match `prefix` +/// exactly. Case-sensitive (enum names are stable strings, not free +/// text). +fn ldcs_match_prefix(ldcs: &[String], prefix: &[&str]) -> bool { + if ldcs.len() < prefix.len() { + return false; + } + ldcs.iter() + .zip(prefix.iter()) + .all(|(got, want)| got == want) +} + +/// Phase B (codec enum subclass walk) — pending. Detects the codec +/// enum class by structural signature (≥20 `new` instructions in +/// ``, ≥0 ldcs) and yields its declared subclass names in +/// ordinal order. The actual codec strings live in each subclass's +/// constant pool and need a per-subclass walk to extract — that step +/// is in the follow-up commit. Until then this returns a structural +/// "the codec enum is class X with N entries" without the names. +#[allow(dead_code)] +pub(crate) fn find_codec_enum(archive: &mut jar::Jar) -> Option { + let mut best: Option<(String, Vec)> = None; + jar::for_each_class(archive, |class_name, class| { + let Some((news, ldcs)) = clinit_news_and_ldcs(class) else { + return; + }; + // Codec enum's has many `new` ops, 0 string ldcs. + if news.len() < 20 || !ldcs.is_empty() { + return; + } + match &best { + None => best = Some((class_name.to_string(), news)), + Some((_, prev)) => { + if news.len() > prev.len() { + best = Some((class_name.to_string(), news)); + } + } + } + }); + best.map(|(class_name, subclass_news)| CodecEnumShape { + class_name, + subclass_news, + }) +} + +#[derive(Debug)] +#[allow(dead_code)] +pub(crate) struct CodecEnumShape { + pub class_name: String, + /// Ordered list of class names referenced by `new` in . + /// One entry per codec enum value; subclass walking (Phase B + /// follow-up) resolves each to a codec string. + pub subclass_news: Vec, +} + +/// Walk `` and return `(new_class_names, ldc_strings)`. Used +/// for the codec-enum shape match where we care about both counts. +#[allow(dead_code)] +fn clinit_news_and_ldcs( + class: &super::class_reader::ClassFile, +) -> Option<(Vec, Vec)> { + let mut news = Vec::new(); + let mut ldcs = Vec::new(); + let mut found = false; + let mut _aastore = 0u32; + for m in &class.methods { + let Some(name) = class.member_name(m) else { + continue; + }; + if name != "" { + continue; + } + found = true; + let Some(code) = m.code(&class.constant_pool) else { + continue; + }; + for insn in code.instructions() { + match insn.opcode { + NEW => { + if let Some(idx) = insn.cp_index() { + if let Some(n) = class.constant_pool.class_name(idx) { + news.push(n.to_string()); + } + } + } + LDC | LDC_W => { + if let Some(idx) = insn.cp_index() { + let s = match class.constant_pool.get(idx) { + Some(CpInfo::String { string_index }) => { + class.constant_pool.utf8(*string_index).map(str::to_string) + } + Some(CpInfo::Utf8(s)) => Some(s.clone()), + _ => None, + }; + if let Some(s) = s { + ldcs.push(s); + } + } + } + AASTORE => _aastore += 1, + _ => {} + } + } + } + if found { Some((news, ldcs)) } else { None } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ldcs_match_prefix_exact() { + let ldcs = vec![ + "English".to_string(), + "French".to_string(), + "Spanish".to_string(), + ]; + assert!(ldcs_match_prefix(&ldcs, &["English", "French"])); + assert!(ldcs_match_prefix(&ldcs, &["English", "French", "Spanish"])); + assert!(!ldcs_match_prefix(&ldcs, &["English", "German"])); + // Too short — prefix longer than ldcs is a mismatch. + assert!(!ldcs_match_prefix( + &ldcs, + &["English", "French", "Spanish", "Dutch"] + )); + } + + #[test] + fn ldcs_match_prefix_is_case_sensitive() { + let ldcs = vec!["english".to_string(), "french".to_string()]; + assert!(!ldcs_match_prefix(&ldcs, &["English", "French"])); + } + + #[test] + fn fingerprint_count_tolerance() { + // Sanity check: tolerance is at least 1, otherwise minor + // framework drift breaks the parser. + assert!(LDC_COUNT_TOLERANCE >= 1); + } + + #[test] + fn fingerprints_cover_documented_enums() { + // Lock the fingerprint roster — if someone adds/removes a + // fingerprint, this test forces them to think about it. The + // 5 documented enums (Language, Purpose, VideoFormat, Region, + // Studio) all need to be here. Codec is structural (separate + // path), not fingerprinted by ldc prefix. + let labels: Vec<&str> = FINGERPRINTS.iter().map(|fp| fp.label).collect(); + assert_eq!( + labels, + vec!["Language", "Purpose", "VideoFormat", "Region", "Studio"] + ); + } + + #[test] + fn fingerprint_prefixes_nonempty_and_under_expected_count() { + // Each prefix must be non-empty and shorter than expected_count + // (so the count gives ADDITIONAL signal beyond the prefix + // match). If a prefix is as long as expected_count there's no + // counting benefit. + for fp in FINGERPRINTS { + assert!(!fp.prefix.is_empty(), "{} has empty prefix", fp.label); + assert!( + fp.prefix.len() < fp.expected_count, + "{} prefix is not shorter than expected_count", + fp.label + ); + } + } +} diff --git a/src/labels/mod.rs b/src/labels/mod.rs index b26831d..3658755 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod class_reader; mod criterion; mod ctrm; mod dbp; +mod deluxe; pub(crate) mod jar; mod paramount; mod pixelogic; @@ -89,7 +90,12 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ // earlier parsers' fast file-presence detects short-circuit and // dbp only runs on discs that fell through everything else. ("dbp", dbp::detect, dbp::parse), - // ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser + // deluxe last for the same reason as dbp: its detect() triggers + // on any top-level .jar (every BD-J disc), and parse() does the + // real `com/bydeluxe/` check. Phase A (master enum identification) + // shipped 2026-05-10; phases B/C/D (per-stream binding decoder) + // pending. + ("deluxe", deluxe::detect, deluxe::parse), ]; /// Search disc for config files, extract labels, apply to streams.