mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55bd1ee868 | ||
|
|
5285e8b6ec | ||
|
|
c5af61e46b | ||
|
|
ed73c1ab68 | ||
|
|
5ce2df0888 | ||
|
|
ea591a36a5 | ||
|
|
368e10486e | ||
|
|
a99c4f8487 | ||
|
|
4df470e572 | ||
|
|
a0b13941fe | ||
|
|
275c9da009 | ||
|
|
da183722fa | ||
|
|
4a5424eabe | ||
|
|
293d89c586 | ||
|
|
4840f5134d | ||
|
|
11c8211605 | ||
|
|
1038beaf06 | ||
|
|
f1df57196c | ||
|
|
8ba9dc1c0b | ||
|
|
49b131f39a | ||
|
|
8238ec4ce7 | ||
|
|
4fd5d27df8 | ||
|
|
1fbe272832 | ||
|
|
6bd635725e | ||
|
|
09b77b4dea | ||
|
|
688058b3e8 | ||
|
|
c96bac7977 | ||
|
|
c32acff3e4 | ||
|
|
b58e2d9873 | ||
|
|
a9e802c1c2 | ||
|
|
a876ce846b | ||
|
|
058fd8396f | ||
|
|
5ee28c08b9 | ||
|
|
764230b1eb | ||
|
|
7fb2e07aed | ||
|
|
376aadb335 | ||
|
|
586495b2ee | ||
|
|
72f2224efe | ||
|
|
0bca7a11bd | ||
|
|
72ab714c1e | ||
|
|
d7fb1b35ed | ||
|
|
06be4defd2 | ||
|
|
a832bad697 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.18.9"
|
||||
version = "0.19.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -247,6 +247,7 @@ fn validate_processing_key(
|
||||
/// Find Verify Media Key Record (type 0x10) in MKB.
|
||||
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
let mut pos = 0;
|
||||
let mut type10_seen: Vec<(usize, usize)> = Vec::new();
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
@@ -254,14 +255,32 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x10 {
|
||||
type10_seen.push((pos, rec_len));
|
||||
}
|
||||
|
||||
if rec_type == 0x10 && rec_len >= 20 {
|
||||
// mk_dv is at offset 4 (after record header)
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
pos,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
return Some(dv);
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
type10_seen = ?type10_seen,
|
||||
scanned_bytes = pos,
|
||||
"no 0x10 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
@@ -628,32 +647,70 @@ pub fn resolve_keys(
|
||||
}
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_start",
|
||||
aacs2,
|
||||
bus_encryption,
|
||||
disc_hash = %hash_hex,
|
||||
mkb_present = mkb_data.is_some(),
|
||||
"resolve_keys: starting"
|
||||
);
|
||||
|
||||
// Path 1: Look up VUK by disc hash in KEYDB
|
||||
if let Some(entry) = keydb.find_disc(&hash_hex) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
|
||||
if let Some(vuk) = entry.vuk {
|
||||
return Some(build(vuk, 1));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_no_vuk", "disc hash entry has no VUK");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
|
||||
}
|
||||
|
||||
// Path 2: Find entry with matching VID → derive VUK from MK + VID
|
||||
let mut path2_mk_did_count = 0usize;
|
||||
for entry in keydb.disc_entries.values() {
|
||||
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||
path2_mk_did_count += 1;
|
||||
if did == *volume_id {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
|
||||
|
||||
// Path 3: MKB + processing keys → media key → VUK
|
||||
if let Some(mkb) = mkb_data {
|
||||
let mk_dv = mkb_find_mk_dv(mkb);
|
||||
let subdiff = mkb_find_subdiff_records(mkb);
|
||||
let cvalues = mkb_find_cvalues(mkb);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_mkb_records",
|
||||
mk_dv_found = mk_dv.is_some(),
|
||||
subdiff_found = subdiff.is_some(),
|
||||
subdiff_len = subdiff.as_ref().map(|s| s.len()).unwrap_or(0),
|
||||
cvalues_found = cvalues.is_some(),
|
||||
cvalues_len = cvalues.as_ref().map(|c| c.len()).unwrap_or(0),
|
||||
"MKB record scan results"
|
||||
);
|
||||
|
||||
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 3));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = keydb.processing_keys.len(), "PK derivation failed");
|
||||
|
||||
// Path 4: MKB + device keys → processing key → media key → VUK
|
||||
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 4));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = keydb.device_keys.len(), "DK derivation failed");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
|
||||
}
|
||||
|
||||
None
|
||||
|
||||
+158
-1
@@ -20,6 +20,35 @@ pub struct ClipInfo {
|
||||
pub ep_coarse: Vec<EpCoarse>,
|
||||
/// Fine EP entries for the primary video stream
|
||||
pub ep_fine: Vec<EpFine>,
|
||||
/// Per-stream metadata from the ProgramInfo section (BD spec).
|
||||
/// Cross-validates the MPLS STN view — see `labels/clpi.rs`.
|
||||
/// Empty when program_info is missing or malformed.
|
||||
pub streams: Vec<ClpiStream>,
|
||||
}
|
||||
|
||||
/// One stream descriptor from the CLPI ProgramInfo / stream_coding_info
|
||||
/// table. Mirrors the same fields the MPLS STN table carries — see
|
||||
/// `mpls::StreamEntry` for the playlist-side equivalent.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ClpiStream {
|
||||
/// PID of the stream in the MPEG-TS (matches MPLS).
|
||||
pub pid: u16,
|
||||
/// SCSI/BD coding type byte (0x80 LPCM, 0x83 TrueHD, 0x86 DTS-HD MA,
|
||||
/// 0x90 PG, etc.). See `labels::mpls_universal::coding_type_to_codec_hint`.
|
||||
pub coding_type: u8,
|
||||
/// ISO 639-2 3-char language code. Empty for video streams.
|
||||
pub language: String,
|
||||
/// Audio format byte (1=mono, 3=stereo, 6=5.1, 12=7.1).
|
||||
/// Zero for non-audio streams.
|
||||
pub audio_format: u8,
|
||||
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz). Zero for non-audio.
|
||||
pub audio_rate: u8,
|
||||
/// Video format byte (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p).
|
||||
/// Zero for non-video.
|
||||
pub video_format: u8,
|
||||
/// Video rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94).
|
||||
pub video_rate: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -128,7 +157,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
|
||||
// Header offsets
|
||||
let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
let _prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
let prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
let cpi_start = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
|
||||
|
||||
// ClipInfo section at offset 40
|
||||
@@ -139,6 +168,16 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
0
|
||||
};
|
||||
|
||||
// Parse ProgramInfo (per-stream language + codec). Best-effort:
|
||||
// malformed program_info doesn't fail the parse, just gives an
|
||||
// empty streams list. EP map is unaffected — sector-range lookups
|
||||
// continue to work.
|
||||
let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() {
|
||||
parse_program_info(&data[prog_info_start..])
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Parse CPI / EP Map
|
||||
let (ep_coarse, ep_fine) = if cpi_start > 0 && cpi_start + 8 < data.len() {
|
||||
parse_cpi(&data[cpi_start..])?
|
||||
@@ -151,9 +190,127 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
source_packet_count,
|
||||
ep_coarse,
|
||||
ep_fine,
|
||||
streams,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
||||
/// language, codec sub-fields). Layout per BD spec / libbluray
|
||||
/// clpi_parse.c:
|
||||
///
|
||||
/// ```text
|
||||
/// ProgramInfo:
|
||||
/// length: 4 bytes
|
||||
/// reserved: 1 byte
|
||||
/// num_programs: 1 byte
|
||||
/// for each program:
|
||||
/// spn_program_sequence_start: 4 bytes
|
||||
/// program_map_pid: 2 bytes
|
||||
/// num_streams: 1 byte
|
||||
/// num_groups: 1 byte
|
||||
/// for each stream:
|
||||
/// pid: 2 bytes
|
||||
/// stream_coding_info_length: 1 byte
|
||||
/// stream_coding_info: (varies by coding_type)
|
||||
/// coding_type: 1 byte
|
||||
/// per-type bytes (see match arms below)
|
||||
/// ```
|
||||
///
|
||||
/// Returns `Vec::new()` on any structural mismatch — we don't propagate
|
||||
/// errors because the EP map is the primary CLPI output, and a corrupt
|
||||
/// program_info shouldn't break sector-range lookups.
|
||||
fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
|
||||
let mut out = Vec::new();
|
||||
if data.len() < 6 {
|
||||
return out;
|
||||
}
|
||||
// length: 4 bytes (skipped — we trust the section bounds in the
|
||||
// caller's slice and read the bytes that follow). Reserved 1 byte
|
||||
// at offset 4. num_programs at offset 5.
|
||||
let num_programs = data[5] as usize;
|
||||
let mut pos = 6usize;
|
||||
for _ in 0..num_programs {
|
||||
// Program header: 4 (spn) + 2 (pmt_pid) + 1 (num_streams) + 1 (num_groups) = 8 bytes
|
||||
if pos + 8 > data.len() {
|
||||
return out;
|
||||
}
|
||||
let num_streams = data[pos + 6] as usize;
|
||||
pos += 8;
|
||||
|
||||
for _ in 0..num_streams {
|
||||
// Stream header: 2 (pid) + 1 (sci_length) + sci bytes
|
||||
if pos + 3 > data.len() {
|
||||
return out;
|
||||
}
|
||||
let pid = u16::from_be_bytes([data[pos], data[pos + 1]]);
|
||||
let sci_len = data[pos + 2] as usize;
|
||||
let sci_end = pos + 3 + sci_len;
|
||||
if sci_end > data.len() || sci_len < 1 {
|
||||
return out;
|
||||
}
|
||||
let sci = &data[pos + 3..sci_end];
|
||||
let coding_type = sci[0];
|
||||
|
||||
let mut audio_format = 0u8;
|
||||
let mut audio_rate = 0u8;
|
||||
let mut video_format = 0u8;
|
||||
let mut video_rate = 0u8;
|
||||
let mut language = String::new();
|
||||
|
||||
match coding_type {
|
||||
// Video — MPEG-2 (0x02), H.264 (0x1B), HEVC (0x24)
|
||||
0x02 | 0x1B | 0x24 => {
|
||||
if sci.len() >= 2 {
|
||||
video_format = (sci[1] >> 4) & 0x0F;
|
||||
video_rate = sci[1] & 0x0F;
|
||||
}
|
||||
}
|
||||
// Primary audio — LPCM(0x80), AC-3(0x81), DTS(0x82),
|
||||
// TrueHD(0x83), AC-3+(0x84), DTS-HD(0x85), DTS-HD MA(0x86)
|
||||
0x80..=0x86 => {
|
||||
if sci.len() >= 2 {
|
||||
audio_format = (sci[1] >> 4) & 0x0F;
|
||||
audio_rate = sci[1] & 0x0F;
|
||||
}
|
||||
if sci.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sci[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
// Secondary audio (0xA1 AC-3+, 0xA2 DTS-HD)
|
||||
0xA1 | 0xA2 => {
|
||||
if sci.len() >= 2 {
|
||||
audio_format = (sci[1] >> 4) & 0x0F;
|
||||
audio_rate = sci[1] & 0x0F;
|
||||
}
|
||||
if sci.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sci[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
// PG (0x90), IG (0x91): coding_type + 3-byte language [+ char_code for PG]
|
||||
0x90 | 0x91 => {
|
||||
if sci.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sci[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
out.push(ClpiStream {
|
||||
pid,
|
||||
coding_type,
|
||||
language,
|
||||
audio_format,
|
||||
audio_rate,
|
||||
video_format,
|
||||
video_rate,
|
||||
});
|
||||
|
||||
pos = sci_end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the CPI section containing the EP map.
|
||||
fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
if data.len() < 8 {
|
||||
|
||||
+101
-5
@@ -22,30 +22,91 @@ impl Disc {
|
||||
) -> Option<HandshakeResult> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
let keydb_path = opts.resolve_keydb()?;
|
||||
let keydb = KeyDb::load(&keydb_path).ok()?;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_entry",
|
||||
"do_handshake entered"
|
||||
);
|
||||
let keydb_path = match opts.resolve_keydb() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_no_keydb",
|
||||
"no KEYDB found in search paths; handshake skipped"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let keydb = match KeyDb::load(&keydb_path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_keydb_load_failed",
|
||||
io_error_kind = ?e.kind(),
|
||||
keydb = %keydb_path.display(),
|
||||
"KEYDB load failed; handshake skipped"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let host_cert_count = keydb.host_certs.len();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_start",
|
||||
host_cert_count,
|
||||
keydb = %keydb_path.display(),
|
||||
"handshake starting"
|
||||
);
|
||||
|
||||
const MAX_CERT_ATTEMPTS: usize = 16;
|
||||
for hc in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS) {
|
||||
let mut last_err_code: Option<u16> = None;
|
||||
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
||||
Ok(mut auth) => {
|
||||
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
||||
Ok(vid) => vid,
|
||||
Err(_) => return None, // handshake succeeded but can't read VID
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_vid_read_failed",
|
||||
cert_index = idx,
|
||||
error_code = e.code(),
|
||||
"auth ok but volume ID read failed"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
.ok()
|
||||
.map(|(rdk, _)| rdk);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_ok",
|
||||
cert_index = idx,
|
||||
has_read_data_key = read_data_key.is_some(),
|
||||
);
|
||||
return Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
last_err_code = Some(e.code());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_all_certs_failed",
|
||||
host_cert_count,
|
||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
||||
last_error_code = last_err_code,
|
||||
"all host certs in KEYDB rejected by drive"
|
||||
);
|
||||
// All host certs failed — return None, not a fake success
|
||||
None
|
||||
}
|
||||
@@ -73,6 +134,19 @@ impl Disc {
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
|
||||
// Log the disc hash so we can confirm whether it's present in KEYDB
|
||||
// when key resolution fails. The disc hash is SHA-1 of the full
|
||||
// Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by.
|
||||
let dh = crate::aacs::disc_hash(&uk_ro_data);
|
||||
let dh_hex = crate::aacs::disc_hash_hex(&dh);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_disc_hash",
|
||||
disc_hash = %dh_hex,
|
||||
uk_ro_len = uk_ro_data.len(),
|
||||
"disc hash computed (compare with keydb.cfg entries)"
|
||||
);
|
||||
|
||||
let cc_data = udf_fs
|
||||
.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
@@ -84,6 +158,28 @@ impl Disc {
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
let mkb_first_64_hex = mkb_data
|
||||
.as_deref()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.take(64)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_mkb_info",
|
||||
mkb_present = mkb_data.is_some(),
|
||||
mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0),
|
||||
mkb_version = ?mkb_ver,
|
||||
mkb_first_64 = %mkb_first_64_hex,
|
||||
keydb_disc_count = keydb.disc_entries.len(),
|
||||
keydb_dk_count = keydb.device_keys.len(),
|
||||
keydb_pk_count = keydb.processing_keys.len(),
|
||||
"AACS resolution inputs"
|
||||
);
|
||||
|
||||
// Use handshake volume ID if available, otherwise zeros
|
||||
// (KEYDB VUK lookup by disc hash works without volume ID)
|
||||
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
||||
|
||||
+246
-57
@@ -55,6 +55,11 @@ pub struct Disc {
|
||||
pub css: Option<crate::css::CssState>,
|
||||
/// Whether this disc requires decryption (AACS or CSS)
|
||||
pub encrypted: bool,
|
||||
/// AACS resolution error when `encrypted` is true and `aacs` is None.
|
||||
/// Lets callers distinguish "no KEYDB found", "KEYDB failed to parse",
|
||||
/// "disc hash not in KEYDB", etc. None when AACS resolution wasn't
|
||||
/// attempted (unencrypted disc) or succeeded.
|
||||
pub aacs_error: Option<crate::error::Error>,
|
||||
/// Content format (BD transport stream vs DVD program stream)
|
||||
pub content_format: ContentFormat,
|
||||
}
|
||||
@@ -1110,14 +1115,45 @@ impl Disc {
|
||||
let encrypted =
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
|
||||
let aacs = if encrypted {
|
||||
if let Some(keydb_path) = opts.resolve_keydb() {
|
||||
Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()).ok()
|
||||
} else {
|
||||
None
|
||||
let (aacs, aacs_error) = if encrypted {
|
||||
match opts.resolve_keydb() {
|
||||
Some(keydb_path) => {
|
||||
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
|
||||
{
|
||||
Ok(state) => (Some(state), None),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_resolve_failed",
|
||||
error_code = e.code(),
|
||||
keydb = %keydb_path.display(),
|
||||
handshake_ok = handshake.is_some(),
|
||||
"AACS key resolution failed"
|
||||
);
|
||||
(None, Some(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_no_keydb",
|
||||
"encrypted disc but no KEYDB found in search paths"
|
||||
);
|
||||
// Reuse KeydbLoad with sentinel path — adding a new Error
|
||||
// variant would be a breaking change for downstream
|
||||
// exhaustive matches. The path string makes the cause
|
||||
// unambiguous to autorip's message switch.
|
||||
(
|
||||
None,
|
||||
Some(crate::error::Error::KeydbLoad {
|
||||
path: String::from("<no keydb in search paths>"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
|
||||
@@ -1174,6 +1210,7 @@ impl Disc {
|
||||
aacs,
|
||||
css,
|
||||
encrypted,
|
||||
aacs_error,
|
||||
content_format,
|
||||
})
|
||||
}
|
||||
@@ -1390,7 +1427,15 @@ impl Disc {
|
||||
) -> Result<CopyResult> {
|
||||
let patch_opts = PatchOptions {
|
||||
decrypt: opts.decrypt,
|
||||
block_sectors: Some(1),
|
||||
// 0.18.13: adaptive batching. patch() reads at 32 sectors
|
||||
// when the drive is healthy, drops to 1 on failure to
|
||||
// probe each sector individually, then climbs back after
|
||||
// 16 consecutive clean singles. Walks NonTrimmed regions
|
||||
// ~32x faster in clean stretches without sacrificing any
|
||||
// per-sector recovery quality — the drop-to-1 retry from
|
||||
// the same position guarantees every sector in a failed
|
||||
// batch is individually probed. See Disc::patch body.
|
||||
block_sectors: Some(32),
|
||||
full_recovery: true,
|
||||
reverse: true,
|
||||
wedged_threshold: 50,
|
||||
@@ -1500,7 +1545,7 @@ impl Disc {
|
||||
let batch: u16 = match opts.batch_sectors {
|
||||
Some(b) => b,
|
||||
None if opts.skip_on_error => ecc_sectors(self.format),
|
||||
None => DEFAULT_BATCH_SECTORS,
|
||||
None => DEFAULT_BATCH_SECTORS_OPTICAL,
|
||||
};
|
||||
|
||||
// Pre-compute the list of NonTried regions before handing the
|
||||
@@ -1901,6 +1946,26 @@ impl Disc {
|
||||
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
|
||||
"Disc::sweep returning"
|
||||
);
|
||||
|
||||
// End-of-pass diagnostic summary (added 2026-05-10 alongside
|
||||
// the per-error timing instrumentation in read_error.rs).
|
||||
// One INFO line per sweep that lets a post-mortem analyst tell
|
||||
// at a glance how much damage the disc + drive saw, without
|
||||
// grepping through the per-error WARN log. The PassSummary
|
||||
// counters come from `ReadCtx`'s accumulated state.
|
||||
let pass_sum = read_ctx.pass_summary();
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "pass1_summary",
|
||||
total_reads_ok = pass_sum.total_reads_ok,
|
||||
total_errors = pass_sum.total_errors,
|
||||
zones_entered = pass_sum.zones_entered,
|
||||
jumps_taken = pass_sum.jumps_taken,
|
||||
bytes_good = stats.bytes_good,
|
||||
bytes_pending = stats.bytes_pending,
|
||||
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
|
||||
"Pass 1 complete"
|
||||
);
|
||||
Ok(CopyResult {
|
||||
bytes_total: total_bytes,
|
||||
bytes_good: stats.bytes_good,
|
||||
@@ -2182,7 +2247,23 @@ impl Disc {
|
||||
);
|
||||
}
|
||||
|
||||
let block_sectors = opts.block_sectors.unwrap_or(1);
|
||||
// Adaptive batching: read at `current_batch`, drop to 1 on
|
||||
// batch-read failure, climb back to `initial_batch` after
|
||||
// ADAPTIVE_UPSCALE_THRESHOLD consecutive single-sector successes.
|
||||
// Rationale: dense damage scattered through a NonTrimmed range
|
||||
// is rare — most "bad ranges" in pass N have lots of good
|
||||
// sectors that swept-by-default landed inside. Batch reads
|
||||
// walk those at ~32x the speed of singles, dropping to 1
|
||||
// only when the drive actually returns an error. Guarantees:
|
||||
// - no good sector is ever marked NonTrimmed because it
|
||||
// was bundled in a failed batch — failed batches are
|
||||
// "split decisions", not recorded failures
|
||||
// - drop-to-1 retries the SAME starting position, so every
|
||||
// sector in the failed batch is individually probed
|
||||
let initial_batch = opts.block_sectors.unwrap_or(1);
|
||||
let mut current_batch: u16 = initial_batch;
|
||||
let mut consecutive_singles_ok: u32 = 0;
|
||||
const ADAPTIVE_UPSCALE_THRESHOLD: u32 = 16;
|
||||
let recovery = opts.full_recovery;
|
||||
|
||||
let mut halted = false;
|
||||
@@ -2219,7 +2300,7 @@ impl Disc {
|
||||
const RANGE_BUDGET_CAP_SECS: u64 = 1800;
|
||||
const MAX_SKIPS_PER_RANGE: u32 = 10;
|
||||
let mut skip_count: u32;
|
||||
let mut buf = vec![0u8; block_sectors as usize * 2048];
|
||||
let mut buf = vec![0u8; initial_batch as usize * 2048];
|
||||
|
||||
// Pass 2 uses smaller sectors (1 vs 32) but same damage detection logic
|
||||
const PASSN_DAMAGE_WINDOW: usize = 16;
|
||||
@@ -2302,7 +2383,7 @@ impl Disc {
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_start",
|
||||
block_sectors,
|
||||
block_sectors = initial_batch,
|
||||
recovery,
|
||||
reverse = opts.reverse,
|
||||
wedged_threshold = opts.wedged_threshold,
|
||||
@@ -2443,13 +2524,13 @@ impl Disc {
|
||||
if block_end <= *range_pos {
|
||||
break;
|
||||
}
|
||||
let span = (block_end - *range_pos).min(block_sectors as u64 * 2048);
|
||||
let span = (block_end - *range_pos).min(current_batch as u64 * 2048);
|
||||
(block_end - span, span)
|
||||
} else {
|
||||
if block_end >= end {
|
||||
break;
|
||||
}
|
||||
let span = (end - block_end).min(block_sectors as u64 * 2048);
|
||||
let span = (end - block_end).min(current_batch as u64 * 2048);
|
||||
(block_end, span)
|
||||
};
|
||||
let lba = (pos / 2048) as u32;
|
||||
@@ -2512,6 +2593,28 @@ impl Disc {
|
||||
if consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD {
|
||||
consecutive_skips_without_recovery = 0;
|
||||
}
|
||||
// Adaptive batching: track clean single-sector reads to
|
||||
// decide when to climb back to `initial_batch`. A batch
|
||||
// read succeeding (count > 1) tells us the drive is healthy
|
||||
// but doesn't accumulate toward upscale — we got back to
|
||||
// batch=1 because of a failure here, we need consistent
|
||||
// health at the slow tempo before scaling up again.
|
||||
if count == 1 && current_batch < initial_batch {
|
||||
consecutive_singles_ok += 1;
|
||||
if consecutive_singles_ok >= ADAPTIVE_UPSCALE_THRESHOLD {
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_adaptive_upscale",
|
||||
from = current_batch,
|
||||
to = initial_batch,
|
||||
consecutive_singles_ok,
|
||||
lba,
|
||||
"adaptive batching: drive stable, climbing back to initial_batch"
|
||||
);
|
||||
current_batch = initial_batch;
|
||||
consecutive_singles_ok = 0;
|
||||
}
|
||||
}
|
||||
damage_window.push(true);
|
||||
if damage_window.len() > PASSN_DAMAGE_WINDOW {
|
||||
damage_window.remove(0);
|
||||
@@ -2609,7 +2712,14 @@ impl Disc {
|
||||
let mut bt_pos = backtrack_start;
|
||||
while bt_pos < backtrack_end {
|
||||
let span =
|
||||
(backtrack_end - bt_pos).min(block_sectors as u64 * 2048);
|
||||
// Backtrack always at count=1: this path
|
||||
// fills a gap that the main loop's damage-
|
||||
// window skip jumped over. Using batched
|
||||
// reads here would lump good sectors into
|
||||
// NonTrimmed marks when the gap contains
|
||||
// even one bad sector. Backtrack is rare
|
||||
// enough that the per-sector cost is fine.
|
||||
(backtrack_end - bt_pos).min(2048);
|
||||
let bt_lba = (bt_pos / 2048) as u32;
|
||||
let bt_count = (span / 2048) as u16;
|
||||
let bt_bytes = bt_count as usize * 2048;
|
||||
@@ -2634,9 +2744,17 @@ impl Disc {
|
||||
}
|
||||
Err(_err) => {
|
||||
blocks_read_failed += 1;
|
||||
// Leave NonTrimmed (not Unreadable) so a later
|
||||
// pass gets another shot. Per the project goal
|
||||
// — "recover 100% of readable data" — and the
|
||||
// multi-pass design's promise: bytes stay
|
||||
// Good-or-Maybe across passes; promotion to
|
||||
// Unreadable is the orchestrator's job at
|
||||
// end-of-recovery (final retry pass complete).
|
||||
// Reference: 2026-05-11 design call.
|
||||
send_or_abort(
|
||||
&pipe,
|
||||
PatchItem::Unreadable {
|
||||
PatchItem::NonTrimmed {
|
||||
pos: bt_pos,
|
||||
len: span,
|
||||
},
|
||||
@@ -2657,9 +2775,37 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Adaptive batching split decision: a batch-read
|
||||
// failure (count > 1) is NOT a recorded failure.
|
||||
// We don't yet know which sector in the batch was
|
||||
// actually bad — could be one, could be many.
|
||||
// Drop to count=1 and retry the SAME starting
|
||||
// position so every sector gets individually
|
||||
// probed. Cursor stays put; loop continues.
|
||||
// Invariants: no good sector ever gets lumped
|
||||
// into a NonTrimmed mark, no spurious
|
||||
// consecutive_failures (which drives wedge
|
||||
// detection), no damage_window pollution from
|
||||
// batch-level signals.
|
||||
if count > 1 {
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_adaptive_split",
|
||||
lba,
|
||||
count,
|
||||
from_batch = current_batch,
|
||||
err_code = err.code(),
|
||||
"adaptive batching: batch read failed, dropping to count=1 to probe individually"
|
||||
);
|
||||
current_batch = 1;
|
||||
consecutive_singles_ok = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks_read_failed += 1;
|
||||
consecutive_failures += 1;
|
||||
consecutive_good_since_skip = 0;
|
||||
consecutive_singles_ok = 0;
|
||||
unreadable_count += 1;
|
||||
|
||||
tracing::warn!(
|
||||
@@ -2820,10 +2966,21 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted - mark as Unreadable
|
||||
// All retries exhausted IN THIS PASS — leave NonTrimmed
|
||||
// so a subsequent pass gets another shot. Bytes stay
|
||||
// Good-or-Maybe across passes; only the orchestrator
|
||||
// (autorip) promotes still-NonTrimmed → Unreadable
|
||||
// after the FINAL retry pass completes. Reference:
|
||||
// 2026-05-11 design call ("good or maybe until all
|
||||
// passes are done, then it's gone"). Pre-fix the
|
||||
// patch loop marked Unreadable here, which gave up
|
||||
// on sectors that a later pass might have recovered
|
||||
// (drive reads are stochastic — same sector that
|
||||
// fails 10x in Pass 2 might succeed on attempt 1 in
|
||||
// Pass 3 after the drive state has shifted).
|
||||
send_or_abort(
|
||||
&pipe,
|
||||
PatchItem::Unreadable {
|
||||
PatchItem::NonTrimmed {
|
||||
pos,
|
||||
len: block_bytes,
|
||||
},
|
||||
@@ -2929,36 +3086,45 @@ impl Disc {
|
||||
"Drive responsive — bad sector cluster, not wedged"
|
||||
);
|
||||
} else if probes_ok == 0 && consecutive_failures >= 10 {
|
||||
// Heuristic suspicion of wedge — NOT the
|
||||
// confirmed wedge_transition log that fires
|
||||
// when the SCSI sense family flips into
|
||||
// Hardware/IllegalRequest. This log just
|
||||
// says "the local zone is fully bad" which
|
||||
// could mean a real wedge OR a fully-bad
|
||||
// cluster on a non-wedged drive. The
|
||||
// wedge_skip handler in read_error.rs is
|
||||
// what actually decides + acts.
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_potential_wedge",
|
||||
phase = "patch_zone_fully_bad",
|
||||
consecutive_failures,
|
||||
lba,
|
||||
range_idx,
|
||||
"All probes failed — possible wedge condition"
|
||||
"patch zone fully bad (10+ failures, all probes failed); \
|
||||
not a wedge unless read_error.rs's wedge_transition also fires"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Redundant second Unreadable mark — preserved
|
||||
// bit-for-bit from the pre-split loop (`record`
|
||||
// is idempotent for same-status replacement of
|
||||
// the same range). Routes through the consumer
|
||||
// like every other state change.
|
||||
// Pair with the earlier NonTrimmed dispatch — same
|
||||
// bytes, same state. Pre-2026-05-11 this was a
|
||||
// second Unreadable mark; now it's NonTrimmed for
|
||||
// the same reason: cross-pass retry survival.
|
||||
send_or_abort(
|
||||
&pipe,
|
||||
PatchItem::Unreadable {
|
||||
PatchItem::NonTrimmed {
|
||||
pos,
|
||||
len: block_bytes,
|
||||
},
|
||||
)?;
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_mapfile_record_unreadable",
|
||||
phase = "patch_mapfile_record_nontrimmed",
|
||||
pos,
|
||||
block_bytes,
|
||||
consecutive_failures,
|
||||
"Mapfile record dispatched as Unreadable"
|
||||
"Mapfile record dispatched as NonTrimmed (retry next pass)"
|
||||
);
|
||||
|
||||
let pause_secs = if err.is_bridge_degradation() {
|
||||
@@ -3073,7 +3239,7 @@ impl Disc {
|
||||
|
||||
if let Some(reporter) = opts.progress {
|
||||
let (s, bad_ranges_now) = read_shared(&shared);
|
||||
let kind = if block_sectors == 1 {
|
||||
let kind = if initial_batch == 1 {
|
||||
crate::progress::PassKind::Scrape {
|
||||
reverse: opts.reverse,
|
||||
}
|
||||
@@ -3161,7 +3327,8 @@ impl Disc {
|
||||
}
|
||||
|
||||
const MAX_BATCH_SECTORS: u16 = 510;
|
||||
const DEFAULT_BATCH_SECTORS: u16 = 60;
|
||||
const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
|
||||
const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192;
|
||||
const MIN_BATCH_SECTORS: u16 = 3;
|
||||
|
||||
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
|
||||
@@ -3238,43 +3405,63 @@ mod severity_tests {
|
||||
}
|
||||
|
||||
/// Detect the maximum transfer size in sectors for a device.
|
||||
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
|
||||
/// For sg devices, resolves the corresponding block device via sysfs.
|
||||
/// Returns a value aligned to 3 sectors (one aligned unit).
|
||||
pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
let dev_name = device_path.rsplit('/').next().unwrap_or("");
|
||||
if dev_name.is_empty() {
|
||||
return DEFAULT_BATCH_SECTORS;
|
||||
return DEFAULT_BATCH_SECTORS_OPTICAL;
|
||||
}
|
||||
|
||||
// For sg devices, find the corresponding block device name
|
||||
let block_name = if dev_name.starts_with("sg") {
|
||||
let block_dir = format!("/sys/class/scsi_generic/{dev_name}/device/block");
|
||||
std::fs::read_dir(&block_dir)
|
||||
.ok()
|
||||
.and_then(|mut entries| entries.next())
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
} else {
|
||||
Some(dev_name.to_string())
|
||||
};
|
||||
|
||||
if let Some(bname) = block_name {
|
||||
let sysfs_path = format!("/sys/block/{bname}/queue/max_hw_sectors_kb");
|
||||
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
|
||||
if let Ok(kb) = content.trim().parse::<u32>() {
|
||||
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
|
||||
let sectors = (kb / 2) as u16;
|
||||
// Align down to 3 (one aligned unit)
|
||||
let aligned = (sectors / 3) * 3;
|
||||
if aligned >= MIN_BATCH_SECTORS {
|
||||
return aligned.min(MAX_BATCH_SECTORS);
|
||||
// Check if optical drive (0x05 = CD/DVD)
|
||||
let is_optical = (|| -> bool {
|
||||
use std::path::Path;
|
||||
let scsi_device_dir = "/sys/class/scsi_device/".to_string();
|
||||
if let Ok(entries) = std::fs::read_dir(&scsi_device_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let device_type_path = entry.path().join("device/type");
|
||||
if Path::new(&device_type_path).exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&device_type_path) {
|
||||
// Type 0x05 (decimal 5) = CD/DVD drive
|
||||
if content.trim().parse::<u32>() == Ok(5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
})();
|
||||
|
||||
if is_optical {
|
||||
// For sg devices, find the corresponding block device name
|
||||
let block_name = if dev_name.starts_with("sg") {
|
||||
let block_dir = format!("/sys/class/scsi_generic/{dev_name}/device/block");
|
||||
std::fs::read_dir(&block_dir)
|
||||
.ok()
|
||||
.and_then(|mut entries| entries.next())
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
} else {
|
||||
Some(dev_name.to_string())
|
||||
};
|
||||
|
||||
if let Some(bname) = block_name {
|
||||
let sysfs_path = format!("/sys/block/{bname}/queue/max_hw_sectors_kb");
|
||||
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
|
||||
if let Ok(kb) = content.trim().parse::<u32>() {
|
||||
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
|
||||
let sectors = (kb / 2) as u16;
|
||||
// Align down to 3 (one aligned unit)
|
||||
let aligned = (sectors / 3) * 3;
|
||||
if aligned >= MIN_BATCH_SECTORS {
|
||||
return aligned.min(MAX_BATCH_SECTORS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DEFAULT_BATCH_SECTORS_OPTICAL
|
||||
} else {
|
||||
DEFAULT_BATCH_SECTORS_BLOCK
|
||||
}
|
||||
// Fallback: safe default well under typical kernel limits
|
||||
DEFAULT_BATCH_SECTORS
|
||||
}
|
||||
|
||||
// ─── Format helpers ────────────────────────────────────────────────────────
|
||||
@@ -3453,6 +3640,7 @@ mod tests {
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
};
|
||||
let gb = disc.capacity_gb();
|
||||
@@ -3538,6 +3726,7 @@ mod tests {
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
+25
-6
@@ -70,14 +70,33 @@ pub(super) enum PatchItem {
|
||||
/// Producer exhausted retries on `[pos, pos+len)`. Consumer records
|
||||
/// the range as `Unreadable`. No file write — the existing zero-fill
|
||||
/// from sweep is preserved in place.
|
||||
///
|
||||
/// Currently unused by `Disc::patch` itself (2026-05-11 design call:
|
||||
/// patch never marks `Unreadable` mid-multipass; bytes stay
|
||||
/// `NonTrimmed` so future passes get another shot at them). Kept
|
||||
/// in the enum for the orchestrator-side end-of-recovery promotion
|
||||
/// (autorip, after the final retry pass completes, promotes
|
||||
/// still-NonTrimmed bytes to Unreadable). When that ships, this
|
||||
/// becomes the variant the orchestrator emits to the same
|
||||
/// PatchSink.
|
||||
#[allow(dead_code)]
|
||||
Unreadable { pos: u64, len: u64 },
|
||||
|
||||
/// Producer hit the per-range skip limit and is leaving the
|
||||
/// remaining bytes as `NonTrimmed` for a future pass. CRITICAL:
|
||||
/// this is not the same as `Unreadable` — sectors we never tried
|
||||
/// stay hopeful. (See the comment at the skip-limit branch in
|
||||
/// `Disc::patch`: ~36% of patch-marked Unreadable sectors are
|
||||
/// actually readable on a later pass.) No file write.
|
||||
/// Producer marks `[pos, pos+len)` as `NonTrimmed`. Used for BOTH
|
||||
/// the per-range skip-limit case (remaining bytes never tried) AND
|
||||
/// individual sector failures (tried-but-failed within a pass).
|
||||
/// Both stay "hopeful" — a later pass retries them.
|
||||
///
|
||||
/// CRITICAL: "NonTrimmed in pass N" does NOT mean "Unreadable
|
||||
/// forever." Drive reads are stochastic: the same sector that
|
||||
/// fails 10 times in Pass 2 may succeed on attempt 1 in Pass 3
|
||||
/// after temperature / bus state / prior-read patterns shift.
|
||||
/// Pre-2026-05-11 patch marked individual failures Unreadable,
|
||||
/// which gave up on sectors that subsequent passes could have
|
||||
/// recovered (historical: ~36% of patch-marked Unreadable
|
||||
/// sectors turned out to be readable in re-rip experiments).
|
||||
/// Promotion to true Unreadable is the orchestrator's job,
|
||||
/// applied once after all retry passes complete.
|
||||
NonTrimmed { pos: u64, len: u64 },
|
||||
}
|
||||
|
||||
|
||||
+362
-104
@@ -72,6 +72,73 @@ pub struct ReadCtx {
|
||||
/// `WEDGE_ABORT_THRESHOLD` consecutive wedges with no good read
|
||||
/// in between → real AbortPass.
|
||||
pub wedge_count: u64,
|
||||
// ── Diagnostic counters (added 2026-05-10) ──
|
||||
//
|
||||
// Aggregate state for post-mortem analysis of wedge incidents.
|
||||
// Every Pass 1 / Pass N sweep now produces a structured summary
|
||||
// at the WARN log on each error AND an end-of-pass INFO summary.
|
||||
// Goal: when a wedge happens, the operator should be able to tell
|
||||
// from the logs whether it was triggered by ONE read at a
|
||||
// physically-damaged sector (immediate failure) or by accumulated
|
||||
// exposure across MANY reads (firmware-state buildup), and what
|
||||
// the timing pattern looked like.
|
||||
/// `Instant` of the most recent successful read. Used to compute
|
||||
/// "time since last good" for the WARN log on each error. None
|
||||
/// before the first successful read.
|
||||
pub last_success_at: Option<std::time::Instant>,
|
||||
/// `Instant` of the most recent failed read. Used to compute
|
||||
/// "time since last error" for the WARN log. None before the
|
||||
/// first error.
|
||||
pub last_error_at: Option<std::time::Instant>,
|
||||
/// Last error's sense-key "family" (Medium / Hardware / IllegalRequest
|
||||
/// / NotReady / Other). Used to detect WEDGE TRANSITIONS — when
|
||||
/// the family changes from Medium → Hardware/IllegalRequest, the
|
||||
/// drive almost certainly just entered fast-fail mode. That
|
||||
/// transition gets its own WARN log so the trace is unambiguous.
|
||||
pub last_error_family: Option<SenseFamily>,
|
||||
/// Sum of all errors observed during this sweep. Reported in the
|
||||
/// end-of-pass summary.
|
||||
pub total_errors: u64,
|
||||
/// Sum of all successful reads during this sweep.
|
||||
pub total_reads_ok: u64,
|
||||
/// Count of damage zones entered (transitions from clean → in-damage).
|
||||
pub zones_entered: u64,
|
||||
/// Count of damage-jumps executed during this sweep.
|
||||
pub jumps_taken: u64,
|
||||
/// True between "first error after a clean period" and "16 consecutive
|
||||
/// good reads after the last error in the cluster." Used to count
|
||||
/// zone entries and to bound zone_reads accurately.
|
||||
pub in_damage_zone: bool,
|
||||
}
|
||||
|
||||
/// Coarse classification of a SCSI sense key for diagnostic logging.
|
||||
/// Wedge-family events (Hardware + IllegalRequest) get their own
|
||||
/// transition log when the sense family changes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SenseFamily {
|
||||
NotReady,
|
||||
Medium,
|
||||
Hardware,
|
||||
IllegalRequest,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl SenseFamily {
|
||||
pub fn from_sense_key(sense_key: u8) -> Self {
|
||||
match sense_key {
|
||||
scsi::SENSE_KEY_NOT_READY => SenseFamily::NotReady,
|
||||
scsi::SENSE_KEY_MEDIUM_ERROR => SenseFamily::Medium,
|
||||
scsi::SENSE_KEY_HARDWARE_ERROR => SenseFamily::Hardware,
|
||||
scsi::SENSE_KEY_ILLEGAL_REQUEST => SenseFamily::IllegalRequest,
|
||||
_ => SenseFamily::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the "wedge family" — Hardware + IllegalRequest are
|
||||
/// the senses the BU40N firmware returns in its fast-fail state.
|
||||
pub fn is_wedge_family(self) -> bool {
|
||||
matches!(self, SenseFamily::Hardware | SenseFamily::IllegalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadCtx {
|
||||
@@ -80,8 +147,13 @@ impl ReadCtx {
|
||||
/// is the one that grinds on the bad ranges. So bisect-on-
|
||||
/// marginal is OFF (failed batches become SkipBlock; whole 32-
|
||||
/// sector blocks marked NonTrimmed for Pass N to revisit), and
|
||||
/// the damage-jump fast-path triggers after just 4 consecutive
|
||||
/// outer-batch failures.
|
||||
/// the damage-jump fast-path triggers after just 1 consecutive
|
||||
/// outer-batch failure — the user's wedge-prevention principle
|
||||
/// (2026-05-11): once the drive returns ANY recoverable error,
|
||||
/// retrying the same LBA quickly is what triggers the firmware
|
||||
/// fast-fail transition. Jump immediately, never retry in Pass 1.
|
||||
/// Pass N owns retries — it gets per-sector timeouts that don't
|
||||
/// hammer the firmware the same way.
|
||||
pub fn for_sweep(batch: u16) -> Self {
|
||||
Self {
|
||||
batch,
|
||||
@@ -91,13 +163,21 @@ impl ReadCtx {
|
||||
damage_window: Vec::with_capacity(16),
|
||||
damage_window_max: 16,
|
||||
damage_threshold_pct: 12,
|
||||
fast_jump_threshold: 4,
|
||||
fast_jump_threshold: 1,
|
||||
jump_multiplier: 1,
|
||||
not_ready_retries: 0,
|
||||
bridge_degradation_count: 0,
|
||||
bisecting: false,
|
||||
bisect_on_marginal: false,
|
||||
wedge_count: 0,
|
||||
last_success_at: None,
|
||||
last_error_at: None,
|
||||
last_error_family: None,
|
||||
total_errors: 0,
|
||||
total_reads_ok: 0,
|
||||
zones_entered: 0,
|
||||
jumps_taken: 0,
|
||||
in_damage_zone: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +204,14 @@ impl ReadCtx {
|
||||
bisecting: false,
|
||||
bisect_on_marginal: true,
|
||||
wedge_count: 0,
|
||||
last_success_at: None,
|
||||
last_error_at: None,
|
||||
last_error_family: None,
|
||||
total_errors: 0,
|
||||
total_reads_ok: 0,
|
||||
zones_entered: 0,
|
||||
jumps_taken: 0,
|
||||
in_damage_zone: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +234,40 @@ impl ReadCtx {
|
||||
if self.damage_window.len() > self.damage_window_max {
|
||||
self.damage_window.remove(0);
|
||||
}
|
||||
// Diagnostic state.
|
||||
self.total_reads_ok += 1;
|
||||
self.last_success_at = Some(std::time::Instant::now());
|
||||
// If we were in a damage zone and accumulated enough good
|
||||
// reads to exit (damage_window now all-good), the zone is
|
||||
// over. Don't reset zones_entered — that's a sweep total.
|
||||
if self.in_damage_zone && self.consecutive_good >= self.damage_window_max as u64 {
|
||||
self.in_damage_zone = false;
|
||||
self.last_error_family = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Final per-pass summary suitable for an INFO log at the end of
|
||||
/// `sweep` / `patch`. Caller renders this to a single structured
|
||||
/// log line.
|
||||
pub fn pass_summary(&self) -> PassSummary {
|
||||
PassSummary {
|
||||
total_reads_ok: self.total_reads_ok,
|
||||
total_errors: self.total_errors,
|
||||
zones_entered: self.zones_entered,
|
||||
jumps_taken: self.jumps_taken,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-of-pass stats logged at INFO for post-mortem analysis. Lets
|
||||
/// an operator answer "how damaged is this disc?" from a single log
|
||||
/// line per pass.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PassSummary {
|
||||
pub total_reads_ok: u64,
|
||||
pub total_errors: u64,
|
||||
pub zones_entered: u64,
|
||||
pub jumps_taken: u64,
|
||||
}
|
||||
|
||||
/// What the caller should do after a read failure. The caller owns the
|
||||
@@ -178,7 +299,42 @@ pub enum ReadAction {
|
||||
// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing
|
||||
// bridge wedges 524 ms after a 5.4-second internal ECC retry. The
|
||||
// post-failure pauses give the drive — and the bridge — time to settle.
|
||||
const POST_FAILURE_PAUSE_SECS: u64 = 1;
|
||||
/// Pause between a failed read and the next read attempt — applied
|
||||
/// uniformly to Pass 1 sweep and Pass N patch.
|
||||
///
|
||||
/// 2026-05-11 reframe: a failed read is a failed read, regardless of
|
||||
/// which pass is running. The prior split (1s for Pass N, 5s for Pass
|
||||
/// 1 via `PASS_1_FAIL_PAUSE_SECS`) was solving an imaginary cost
|
||||
/// problem — real damaged-disc cases mark <50 MB NonTrimmed, and the
|
||||
/// extra 5s/error is single-digit minutes per pass, not hours. The
|
||||
/// cost of NOT pausing — a drive wedge that aborts the entire
|
||||
/// multi-pass recovery — is much worse.
|
||||
///
|
||||
/// The wedge avoidance principle: error → drive ECC retry (5-10s
|
||||
/// internal) → return → cooldown pause → next read. Same shape
|
||||
/// everywhere reads can fail.
|
||||
const FAIL_PAUSE_SECS: u64 = 5;
|
||||
/// Long cooldown applied when a damage zone is first entered (the
|
||||
/// FIRST read failure after a clean run, before the drive has had a
|
||||
/// chance to cycle in retries that push it toward fast-fail).
|
||||
///
|
||||
/// Empirical: 2026-05-11 Dune Pt 2 wedge incident showed 7 medium
|
||||
/// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the
|
||||
/// BU40N's firmware into IllegalRequest fast-fail mode permanently.
|
||||
/// Once there, only physical eject + reload clears it. Giving the
|
||||
/// drive 30s of breathing room after the FIRST error in a zone —
|
||||
/// before we start adding more error counts in the firmware's
|
||||
/// internal window — prevents the transition.
|
||||
///
|
||||
/// Cost on clean discs: zero (first-error path doesn't trigger).
|
||||
/// Cost on damaged discs: ~30s × N damage zones; on a 5-zone disc
|
||||
/// that's 2.5 min extra. Trade for never wedging the drive.
|
||||
const ZONE_ENTRY_COOLDOWN_SECS: u64 = 30;
|
||||
/// Cooldown when a long streak of failures suggests the drive is
|
||||
/// stuck in a damage zone and needs MORE breathing room than the
|
||||
/// standard inter-error pause. Same value as `FAIL_PAUSE_SECS`
|
||||
/// because empirically 5s is enough; kept as a separate name so the
|
||||
/// escalation policy is explicit at the call site.
|
||||
const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5;
|
||||
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
|
||||
const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2;
|
||||
@@ -187,14 +343,6 @@ const NOT_READY_MAX_RETRIES: u32 = 3;
|
||||
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15;
|
||||
const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;
|
||||
|
||||
/// Pass 1 inter-error pause. ZERO on the clean path (sweep zooms
|
||||
/// past damage windows) but applied to every failed read so the
|
||||
/// drive's firmware gets cool-down time between damage-cluster
|
||||
/// exposures. Wedge-avoidance change 2026-05-10: pre-fix `pause_secs
|
||||
/// = 0` on Pass 1 errors caused back-to-back exposures that
|
||||
/// accumulated firmware wedge state.
|
||||
const PASS_1_FAIL_PAUSE_SECS: u64 = 5;
|
||||
|
||||
/// Base of the damage-jump distance formula: `jump_sectors =
|
||||
/// JUMP_BASE_SECTORS × batch × jump_multiplier`. Bumped 2026-05-10
|
||||
/// from 256 → 1024 (4×) so the first damage-jump at batch=32 covers
|
||||
@@ -244,6 +392,15 @@ const WEDGE_PAUSE_SECS: u64 = 30;
|
||||
/// a permanently bricked drive.
|
||||
const WEDGE_ABORT_THRESHOLD: u64 = 16;
|
||||
|
||||
/// Pass-N wedge-skip distance. Pass N's batch=1 reads target
|
||||
/// specific NonTrimmed sectors from Pass 1, so a big 1 GB skip
|
||||
/// would blow past the current NonTrimmed range and abandon many
|
||||
/// sectors that might still recover. Use a smaller skip just to
|
||||
/// move past the bricked LBA + a small buffer — the outer patch
|
||||
/// loop's next iteration picks up the next sector in the same or
|
||||
/// next range.
|
||||
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;
|
||||
|
||||
/// THE single error-handling entry point. Updates `ctx`, returns the
|
||||
/// action the caller must apply.
|
||||
///
|
||||
@@ -261,12 +418,53 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
ctx.consecutive_outer_failures += 1;
|
||||
}
|
||||
|
||||
// Diagnostic instrumentation — compute timing context BEFORE
|
||||
// mutating the timestamps so the log reflects the gap to the
|
||||
// PREVIOUS error / success, not zero.
|
||||
let now = std::time::Instant::now();
|
||||
let ms_since_last_error = ctx
|
||||
.last_error_at
|
||||
.map(|t| now.duration_since(t).as_millis() as u64);
|
||||
let ms_since_last_success = ctx
|
||||
.last_success_at
|
||||
.map(|t| now.duration_since(t).as_millis() as u64);
|
||||
|
||||
let current_family = err
|
||||
.scsi_sense()
|
||||
.map(|s| SenseFamily::from_sense_key(s.sense_key))
|
||||
.unwrap_or(SenseFamily::Other);
|
||||
|
||||
// Zone-entry tracking: this is the first error after a clean run
|
||||
// (or the first error of the sweep).
|
||||
if !ctx.in_damage_zone && !ctx.bisecting {
|
||||
ctx.in_damage_zone = true;
|
||||
ctx.zones_entered += 1;
|
||||
}
|
||||
|
||||
ctx.total_errors += 1;
|
||||
ctx.last_error_at = Some(now);
|
||||
|
||||
// Wedge transition: previous error was MEDIUM, this one is
|
||||
// HARDWARE or ILLEGAL_REQUEST. That's the moment the drive's
|
||||
// firmware flipped into fast-fail mode. Distinct WARN so logs
|
||||
// make it unambiguous when the wedge "started."
|
||||
let is_wedge_transition = matches!(ctx.last_error_family, Some(prev) if !prev.is_wedge_family())
|
||||
&& current_family.is_wedge_family();
|
||||
ctx.last_error_family = Some(current_family);
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "read_error",
|
||||
consecutive_failures = ctx.consecutive_failures,
|
||||
consecutive_outer_failures = ctx.consecutive_outer_failures,
|
||||
ms_since_last_error,
|
||||
ms_since_last_success,
|
||||
total_errors = ctx.total_errors,
|
||||
total_reads_ok = ctx.total_reads_ok,
|
||||
batch = ctx.batch,
|
||||
bisecting = ctx.bisecting,
|
||||
wedge_count = ctx.wedge_count,
|
||||
sense_family = ?current_family,
|
||||
sense_key = err.scsi_sense().map(|s| s.sense_key),
|
||||
asc = err.scsi_sense().map(|s| s.asc),
|
||||
ascq = err.scsi_sense().map(|s| s.ascq),
|
||||
@@ -274,6 +472,17 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
"read failed; classifying"
|
||||
);
|
||||
|
||||
if is_wedge_transition {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "wedge_transition",
|
||||
errors_in_zone = ctx.total_errors,
|
||||
ms_since_last_success,
|
||||
new_family = ?current_family,
|
||||
"drive entered wedge / fast-fail family (was returning recoverable medium errors before this)"
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Transport failure: bridge crash / USB disconnect. The outer
|
||||
// pass loop knows how to handle this (rediscover sg path,
|
||||
// re-open drive). Inline single-sector retry here was tried in
|
||||
@@ -311,27 +520,27 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
|
||||
// 4. Hardware error / illegal request — the firmware-wedge family.
|
||||
// The drive transitioned into a fast-fail state where it
|
||||
// rejects reads near the LBA. Two policies:
|
||||
// rejects reads near the LBA. Same response shape for both
|
||||
// passes (2026-05-11 reframe — error handling is centralized,
|
||||
// and the wedge is a code-induced state we can avoid via
|
||||
// pacing + skip):
|
||||
//
|
||||
// Pass 1 sweep (bisect_on_marginal=false): the wedge is
|
||||
// *recoverable* by skipping. Jump a large distance ahead
|
||||
// (1 GB), pause for cooldown, mark the skipped region
|
||||
// NonTrimmed so Pass N revisits. Allow up to
|
||||
// WEDGE_ABORT_THRESHOLD consecutive wedges before truly
|
||||
// giving up. This replaces the pre-fix "abort the entire
|
||||
// rip on first wedge" behavior that caused 48%-and-die
|
||||
// failures on discs with one bad cluster.
|
||||
// - Pass 1 sweep (bisect_on_marginal=false): jump
|
||||
// WEDGE_JUMP_SECTORS (1 GB) ahead, pause WEDGE_PAUSE_SECS,
|
||||
// mark skipped region NonTrimmed.
|
||||
// - Pass N patch (bisect_on_marginal=true): give up on the
|
||||
// current sector (the granular target), pause for cooldown,
|
||||
// let the outer patch loop move to the next NonTrimmed
|
||||
// range. Implemented as a small JumpAhead so the same code
|
||||
// path serves both — Pass N's batch=1 means JumpAhead by
|
||||
// WEDGE_PASS_N_SKIP_SECTORS effectively skips just this
|
||||
// sector and a small buffer (gives the drive room to
|
||||
// recover before the next per-sector attempt).
|
||||
//
|
||||
// Pass N patch (bisect_on_marginal=true): Pass N's job IS
|
||||
// single-sector recovery; a wedge means the drive won't
|
||||
// give us the specific sectors we asked for. Skipping
|
||||
// doesn't help here. Abort and let autorip decide whether
|
||||
// to retry, eject, or surface the failure to the user.
|
||||
// Both paths share the WEDGE_ABORT_THRESHOLD budget — only
|
||||
// AbortPass after N consecutive wedges with no successful
|
||||
// read in between.
|
||||
if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
|
||||
if ctx.bisect_on_marginal {
|
||||
return ReadAction::AbortPass;
|
||||
}
|
||||
// Pass 1 wedge-skip path.
|
||||
if !ctx.bisecting {
|
||||
ctx.wedge_count += 1;
|
||||
}
|
||||
@@ -341,20 +550,28 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
phase = "wedge_abort",
|
||||
wedge_count = ctx.wedge_count,
|
||||
threshold = WEDGE_ABORT_THRESHOLD,
|
||||
"Pass 1 wedge-skip exhausted — drive appears permanently stuck"
|
||||
pass = if ctx.bisect_on_marginal { "N" } else { "1" },
|
||||
"wedge-skip exhausted — drive appears permanently stuck"
|
||||
);
|
||||
return ReadAction::AbortPass;
|
||||
}
|
||||
let jump_sectors = if ctx.bisect_on_marginal {
|
||||
WEDGE_PASS_N_SKIP_SECTORS
|
||||
} else {
|
||||
WEDGE_JUMP_SECTORS
|
||||
};
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "wedge_skip",
|
||||
pass = if ctx.bisect_on_marginal { "N" } else { "1" },
|
||||
wedge_count = ctx.wedge_count,
|
||||
jump_sectors = WEDGE_JUMP_SECTORS,
|
||||
jump_sectors,
|
||||
pause_secs = WEDGE_PAUSE_SECS,
|
||||
"Pass 1 wedge detected — jumping ahead and pausing for drive cooldown"
|
||||
"wedge detected — skipping ahead and pausing for drive cooldown"
|
||||
);
|
||||
ctx.jumps_taken += 1;
|
||||
return ReadAction::JumpAhead {
|
||||
sectors: WEDGE_JUMP_SECTORS,
|
||||
sectors: jump_sectors,
|
||||
pause_secs: WEDGE_PAUSE_SECS,
|
||||
};
|
||||
}
|
||||
@@ -403,35 +620,31 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
bad_count * 100 / ctx.damage_window.len()
|
||||
};
|
||||
|
||||
// Inter-error pause on Pass 1 — wedge avoidance.
|
||||
// Inter-error pause — wedge prevention via pacing.
|
||||
//
|
||||
// Pre-2026-05-10 Pass 1 ran `pause_secs = 0` on all errors so
|
||||
// sweep would zoom past damage zones in seconds instead of
|
||||
// minutes. Empirically on the BU40N this caused firmware-wedge
|
||||
// events: each read failure leaves residual state in the drive's
|
||||
// firmware, and back-to-back errors without cooldown accumulate
|
||||
// toward a wedge threshold. We hit wedge on Dune Pt 2 after 5
|
||||
// errors in 43 s spread over a 140 MB damage cluster — even
|
||||
// though wall-clock pacing was slow, the drive had no breathing
|
||||
// room between exposures.
|
||||
// Zone-entry case (first error after a clean run): apply the
|
||||
// long ZONE_ENTRY_COOLDOWN_SECS pause. The empirical wedge
|
||||
// observed 2026-05-11 happened ~7 errors into a damage zone,
|
||||
// each retry adding to the firmware's internal counter. A 30s
|
||||
// pause at zone entry lets the drive's bridge / firmware
|
||||
// counters reset before we issue the next read.
|
||||
//
|
||||
// New policy: Pass 1 keeps `pause = 0` on the CLEAN path
|
||||
// (successful reads zoom past damage windows fine), but every
|
||||
// failed read takes a PASS_1_FAIL_PAUSE_SECS pause before the
|
||||
// next read. Cost: ~5 s extra per scattered failure, ~30-60 s
|
||||
// total in a damage cluster — trivial compared to the alternative
|
||||
// of crashing the whole rip at 48%. Long failure streaks still
|
||||
// escalate via CONSECUTIVE_FAIL_LONG_PAUSE.
|
||||
let pause_secs = if !ctx.bisect_on_marginal {
|
||||
if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
|
||||
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
|
||||
} else {
|
||||
PASS_1_FAIL_PAUSE_SECS
|
||||
}
|
||||
// Subsequent errors in the same zone: the standard 5s pause.
|
||||
// (We've already jumped past the initial damage; further errors
|
||||
// mean we landed in another bad cluster — same pacing applies.)
|
||||
//
|
||||
// Long-streak escalation: same 5s currently; kept as a separate
|
||||
// branch for future tuning. Pass N (bisect_on_marginal=true)
|
||||
// uses the standard pauses — it's running single-sector retries
|
||||
// on already-known-bad LBAs by design.
|
||||
let is_zone_entry =
|
||||
ctx.consecutive_outer_failures == 1 && !ctx.bisecting && !ctx.bisect_on_marginal;
|
||||
let pause_secs = if is_zone_entry {
|
||||
ZONE_ENTRY_COOLDOWN_SECS
|
||||
} else if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
|
||||
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
|
||||
} else {
|
||||
POST_FAILURE_PAUSE_SECS
|
||||
FAIL_PAUSE_SECS
|
||||
};
|
||||
|
||||
// 7. Damage-jump: too many failures → skip ahead by an escalating
|
||||
@@ -474,6 +687,7 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
// doesn't keep firing fast-jump every read after the initial
|
||||
// jump fired. The window-based trigger handles further jumps.
|
||||
ctx.consecutive_outer_failures = 0;
|
||||
ctx.jumps_taken += 1;
|
||||
return ReadAction::JumpAhead {
|
||||
sectors,
|
||||
pause_secs: pause_secs + POST_JUMP_EXTRA_PAUSE_SECS,
|
||||
@@ -535,15 +749,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_marginal_skips_instead_of_bisecting() {
|
||||
// Pass 1's job is "fast and accurate" — leave bisection to
|
||||
// Pass N. A failed batch becomes SkipBlock (whole 32-sector
|
||||
// block marked NonTrimmed for Pass N to revisit).
|
||||
fn pass_1_marginal_jumps_immediately_not_bisecting() {
|
||||
// 2026-05-11 wedge-prevention rewrite: Pass 1 jumps on the
|
||||
// FIRST marginal error (fast_jump_threshold=1) rather than
|
||||
// SkipBlock. Retrying the same LBA quickly is what triggers
|
||||
// the BU40N's firmware fast-fail transition; immediate jump
|
||||
// prevents the cascade. Pass N still bisects (its job is
|
||||
// per-sector recovery on already-known-bad LBAs).
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::SkipBlock { .. } => {}
|
||||
other => panic!("expected SkipBlock for Pass 1, got {other:?}"),
|
||||
ReadAction::JumpAhead { .. } => {}
|
||||
other => panic!("expected JumpAhead on first Pass 1 marginal error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,26 +786,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_jumps_after_4_consecutive_outer_failures() {
|
||||
// The fast-entry trigger: Pass 1 should JumpAhead after 4
|
||||
// consecutive outer-batch failures, BEFORE the 16-block
|
||||
// damage window has filled. Otherwise we spend ~40 minutes
|
||||
// of bisecting/grinding to fill the window before the first
|
||||
// jump on a damage zone we entered cleanly.
|
||||
fn pass_1_jumps_immediately_on_first_outer_failure() {
|
||||
// 2026-05-11 rewrite: fast_jump_threshold is 1 on Pass 1, not
|
||||
// 4. Even ONE error triggers a jump because BU40N's firmware
|
||||
// fast-fail mode is sensitive to retry cadence. The wedge
|
||||
// observed 2026-05-11 happened at 7 errors / 6.5s — by then
|
||||
// we were already wedged. Jumping on error #1 means we
|
||||
// physically can't reach the cascade.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
// First three should NOT jump (still under threshold of 4).
|
||||
for _ in 0..3 {
|
||||
let a = handle_read_error(&medium_err(), &mut ctx);
|
||||
assert!(
|
||||
!matches!(a, ReadAction::JumpAhead { .. }),
|
||||
"should not jump until 4 consecutive outer failures"
|
||||
);
|
||||
}
|
||||
// Fourth should jump.
|
||||
let a = handle_read_error(&medium_err(), &mut ctx);
|
||||
assert!(
|
||||
matches!(a, ReadAction::JumpAhead { .. }),
|
||||
"expected JumpAhead at 4th consecutive outer failure, got {a:?}"
|
||||
"expected JumpAhead on first outer failure (fast_jump_threshold=1), got {a:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -609,12 +818,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn outer_success_resets_consecutive_outer_failures() {
|
||||
// With fast_jump_threshold=1 each Pass 1 error fires a jump
|
||||
// and resets `consecutive_outer_failures` to 0 inside the
|
||||
// handler. So we can't accumulate "3" the old way — instead,
|
||||
// verify the counter goes back to 0 after on_success too.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
for _ in 0..3 {
|
||||
handle_read_error(&medium_err(), &mut ctx);
|
||||
}
|
||||
assert_eq!(ctx.consecutive_outer_failures, 3);
|
||||
// An outer-success (bisecting=false) should reset the counter.
|
||||
handle_read_error(&medium_err(), &mut ctx);
|
||||
// After fast-jump, consecutive_outer_failures already 0.
|
||||
assert_eq!(ctx.consecutive_outer_failures, 0);
|
||||
// on_success keeps it at 0 (defensive).
|
||||
ctx.bisecting = false;
|
||||
ctx.on_success();
|
||||
assert_eq!(ctx.consecutive_outer_failures, 0);
|
||||
@@ -698,13 +910,40 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_hardware_error_still_aborts() {
|
||||
// Pass N (bisect_on_marginal=true) keeps the original
|
||||
// AbortPass behavior — single-sector recovery can't make
|
||||
// progress through a wedge, so the right answer is to bail
|
||||
// and let the outer layer decide.
|
||||
fn pass_n_hardware_error_also_skips_not_aborts() {
|
||||
// 2026-05-11 reframe: error handling is centralized, the
|
||||
// wedge is a code-induced state, and the avoidance principle
|
||||
// (skip + pause + continue) applies to Pass N too. Previously
|
||||
// Pass N AbortPass'd on first wedge — same fatal-at-48% bug
|
||||
// Pass 1 had pre-fix. Now Pass N gets a smaller skip
|
||||
// (WEDGE_PASS_N_SKIP_SECTORS, not the 1 GB Pass 1 jump)
|
||||
// because Pass N's job IS to revisit specific NonTrimmed
|
||||
// ranges; over-skipping abandons recoverable sectors.
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::JumpAhead {
|
||||
sectors,
|
||||
pause_secs,
|
||||
} => {
|
||||
assert_eq!(sectors, WEDGE_PASS_N_SKIP_SECTORS);
|
||||
assert_eq!(pause_secs, WEDGE_PAUSE_SECS);
|
||||
}
|
||||
other => panic!("expected JumpAhead, got {other:?}"),
|
||||
}
|
||||
assert_eq!(ctx.wedge_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_hardware_error_aborts_after_threshold() {
|
||||
// Same threshold as Pass 1 — after WEDGE_ABORT_THRESHOLD
|
||||
// consecutive wedges with no good read in between, give up.
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
for _ in 0..WEDGE_ABORT_THRESHOLD - 1 {
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
assert!(matches!(action, ReadAction::JumpAhead { .. }));
|
||||
}
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
assert_eq!(action, ReadAction::AbortPass);
|
||||
}
|
||||
|
||||
@@ -741,24 +980,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_pauses_briefly_on_skip_for_wedge_avoidance() {
|
||||
// Pre-2026-05-10 Pass 1 ran pause_secs=0 on all errors (zoom
|
||||
// past damage zones in seconds). That caused firmware wedges
|
||||
// on the BU40N — back-to-back errors with no cooldown built
|
||||
// up firmware state until the drive entered the wedge fast-
|
||||
// fail mode. New policy: a brief inter-error pause (5 s) on
|
||||
// Pass 1 to give the drive's firmware time to settle between
|
||||
// damage-zone exposures. Successful reads remain zero-pause
|
||||
// — only errors cost time, and only a few seconds per
|
||||
// scattered failure. Trivial cost compared to crashing the
|
||||
// whole rip at 48%.
|
||||
fn pass_1_zone_entry_uses_long_cooldown() {
|
||||
// 2026-05-11 wedge-prevention rewrite: Pass 1's FIRST error
|
||||
// (zone entry) gets a 30 s ZONE_ENTRY_COOLDOWN_SECS pause +
|
||||
// a 2 s POST_JUMP_EXTRA on top (since we're also jumping).
|
||||
// The long pause prevents the retry cadence that triggers
|
||||
// firmware fast-fail. Subsequent errors in the same zone fall
|
||||
// back to the standard 5 s FAIL_PAUSE_SECS.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::SkipBlock { pause_secs } => {
|
||||
assert_eq!(pause_secs, PASS_1_FAIL_PAUSE_SECS);
|
||||
ReadAction::JumpAhead { pause_secs, .. } => {
|
||||
assert_eq!(
|
||||
pause_secs,
|
||||
ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS,
|
||||
"first-error pause should be 30 + 2 = 32 s"
|
||||
);
|
||||
}
|
||||
other => panic!("expected SkipBlock, got {other:?}"),
|
||||
other => panic!("expected JumpAhead on first Pass 1 error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_pauses_uniformly_on_failed_read() {
|
||||
// Pass N (bisect_on_marginal=true) is exempt from the
|
||||
// zone-entry long pause — its whole job is to retry single
|
||||
// sectors on already-known-bad LBAs, and the 30 s pause every
|
||||
// single-sector failure would multiply slow recovery
|
||||
// pointlessly. Pass N keeps the standard 5 s FAIL_PAUSE_SECS.
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::SkipBlock { pause_secs } => assert_eq!(pause_secs, FAIL_PAUSE_SECS),
|
||||
ReadAction::JumpAhead { pause_secs, .. } => {
|
||||
assert_eq!(pause_secs, FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS)
|
||||
}
|
||||
ReadAction::Bisect => {}
|
||||
other => panic!("expected pausing action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -27,5 +27,7 @@ pub(crate) use writeback_file::WritebackFile;
|
||||
// caller — the targeted `#[allow]` keeps the re-export visible without
|
||||
// dragging the rest of the module under `dead_code`.
|
||||
#[allow(unused_imports)]
|
||||
pub use pipeline::WRITE_THROUGH_DEPTH;
|
||||
pub use pipeline::{DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink};
|
||||
pub use pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
+80
-20
@@ -27,15 +27,11 @@
|
||||
//! - Consumer panic is converted into
|
||||
//! `Error::IoError { source: io::Error::other(...) }`.
|
||||
//!
|
||||
//! ## Dead-code suppression
|
||||
//! ## Debug logging
|
||||
//!
|
||||
//! Both `Disc::sweep` (`DEFAULT_PIPELINE_DEPTH`, `spawn_named`,
|
||||
//! `try_send`) and `Disc::patch` (`WRITE_THROUGH_DEPTH`, `Flow::Stop`)
|
||||
//! are now in-tree callers (0.18 round 2). The targeted `#[allow]`
|
||||
//! markers below cover the corners of the API not exercised by either
|
||||
//! call site (e.g. `Pipeline::spawn` without a name; `Flow::Stop`
|
||||
//! return path from sweep's sink — sweep always consumes the producer's
|
||||
//! full work-list). Drop the allows once mux migrates.
|
||||
//! Set `FREEMKV_DEBUG=1` environment variable to enable verbose debug
|
||||
//! logging throughout the pipeline (channel sends/receives, backpressure,
|
||||
//! consumer lag detection). This is critical for diagnosing stalls.
|
||||
|
||||
use std::io;
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
@@ -43,20 +39,29 @@ use std::thread::{self, JoinHandle};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// Check if verbose debug logging is enabled via FREEMKV_DEBUG env var.
|
||||
pub fn debug_enabled() -> bool {
|
||||
std::env::var("FREEMKV_DEBUG")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v == "true" || v == "yes")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Default channel depth for callers without a specific reason to
|
||||
/// pick another value.
|
||||
///
|
||||
/// Empirically tuned for sweep and mux — both want enough slack that
|
||||
/// short consumer stalls don't immediately back up onto the producer,
|
||||
/// but not so much that a producer outpacing the consumer accumulates
|
||||
/// arbitrary buffered work. `4` matches the depth sweep has used
|
||||
/// since 0.17.11 (originally in `disc/sweep_pipeline.rs`, now in
|
||||
/// `disc/sweep.rs::SweepSink`). Patch should usually use
|
||||
/// [`WRITE_THROUGH_DEPTH`] (`1`) instead — write-through gives clean
|
||||
/// back-pressure between every read attempt and the matching write,
|
||||
/// which matters when the consumer is updating the mapfile in lockstep.
|
||||
/// pick another value. Kept conservative (4) — most callers should
|
||||
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
|
||||
|
||||
/// Read pipeline depth. Larger buffer compensates for drive variability
|
||||
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
|
||||
/// consumer blocks on write.
|
||||
pub const READ_PIPELINE_DEPTH: usize = 32;
|
||||
|
||||
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
||||
/// sync_file_range blocks; prevents producer from accumulating too much
|
||||
/// work while consumer waits for NFS to drain.
|
||||
pub const WRITE_PIPELINE_DEPTH: usize = 16;
|
||||
|
||||
/// Channel depth for write-through pipelines. Each `send` fully
|
||||
/// drains before the next can enqueue. Use this when the producer
|
||||
/// must observe consumer side-effects (e.g. mapfile state) before
|
||||
@@ -149,6 +154,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
let mut stopped = false;
|
||||
|
||||
while let Ok(item) = rx.recv() {
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline receive: item={}", std::any::type_name::<I>());
|
||||
}
|
||||
|
||||
let apply_start = std::time::Instant::now();
|
||||
|
||||
if first_err.is_some() || stopped {
|
||||
// Drain remaining items so the producer never
|
||||
// blocks on a dead receiver. `apply` is not
|
||||
@@ -159,11 +170,32 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
Ok(Flow::Continue) => {}
|
||||
Ok(Flow::Stop) => {
|
||||
stopped = true;
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline: consumer returned Flow::Stop");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
|
||||
}
|
||||
first_err = Some(e);
|
||||
}
|
||||
}
|
||||
|
||||
let apply_elapsed = apply_start.elapsed();
|
||||
if debug_enabled() && apply_elapsed > std::time::Duration::from_millis(100) {
|
||||
tracing::debug!(
|
||||
"Pipeline apply: took {:.2}s, item={}",
|
||||
apply_elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!(
|
||||
"Pipeline apply: OK in {:.3}ms, item={}",
|
||||
apply_elapsed.as_micros(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match first_err {
|
||||
@@ -188,7 +220,35 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// independent signal (e.g. `Halt`) — `send` alone is not the
|
||||
/// notification edge.
|
||||
pub fn send(&self, item: I) -> Result<(), I> {
|
||||
self.tx.send(item).map_err(|e| e.0)
|
||||
let start = std::time::Instant::now();
|
||||
match self.tx.send(item) {
|
||||
Ok(()) => {
|
||||
let elapsed = start.elapsed();
|
||||
if debug_enabled() && elapsed > std::time::Duration::from_millis(10) {
|
||||
tracing::debug!(
|
||||
"Pipeline send: blocked {:.2}s, item={}",
|
||||
elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let elapsed = start.elapsed();
|
||||
if debug_enabled() && elapsed > std::time::Duration::from_millis(10) {
|
||||
tracing::debug!(
|
||||
"Pipeline send: blocked {:.2}s before channel closed, item={}",
|
||||
elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
|
||||
}
|
||||
Err(e.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking variant of [`Pipeline::send`]. If the channel is
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::path::Path;
|
||||
|
||||
use super::writeback::WritebackPipeline;
|
||||
|
||||
const CHUNK_BYTES: u64 = 32 * 1024 * 1024;
|
||||
const WRITEBACK_CHUNK_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
pub(crate) struct WritebackFile {
|
||||
file: File,
|
||||
@@ -42,7 +42,7 @@ impl WritebackFile {
|
||||
/// or appended files).
|
||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
||||
let pos = file.stream_position()?;
|
||||
let pipeline = WritebackPipeline::new(&file, pos, CHUNK_BYTES);
|
||||
let pipeline = WritebackPipeline::new(&file, pos, WRITEBACK_CHUNK_BYTES);
|
||||
Ok(Self {
|
||||
file,
|
||||
pipeline,
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
//! BDMV disc-library metadata (`/BDMV/META/DL/bdmt_<lang>.xml`).
|
||||
//!
|
||||
//! Every commercial Blu-ray carries a disc-library metadata directory
|
||||
//! with one XML file per shipped language. The schema is the Blu-ray
|
||||
//! "disc library metadata" namespace (`urn:BDA:bdmv;disclibmeta`),
|
||||
//! conventionally prefixed `di:`. Fields commonly present:
|
||||
//!
|
||||
//! - `<di:title>` or `<di:name>` — the title string. Vendor practice
|
||||
//! varies (Paramount discs tend to use `<di:name>`).
|
||||
//! - `<di:description>` — optional synopsis (often absent on retail
|
||||
//! discs; common on box sets and special editions).
|
||||
//! - `<di:discNumber>` / `<di:numSets>` (or `<di:numberOfSets>`) —
|
||||
//! set position for multi-disc releases.
|
||||
//!
|
||||
//! This module is intentionally separate from the BD-J `StreamLabel`
|
||||
//! parsers under `labels/*.rs`. The XML here is disc-level (title,
|
||||
//! description, set position), not per-stream — wiring into the main
|
||||
//! parser registry happens elsewhere.
|
||||
//!
|
||||
//! Real-world XML is irregular: missing description elements, multiple
|
||||
//! title elements (first one wins), and occasional malformed content.
|
||||
//! Extraction is best-effort — a malformed file is treated as "no
|
||||
//! metadata" (returns `None` from the helper), and the caller can
|
||||
//! still get metadata from sibling-language XML files.
|
||||
|
||||
// The module wiring (registry hook + public re-export) is added
|
||||
// separately. Until then the parse/detect entry points have no
|
||||
use super::xml;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`.
|
||||
///
|
||||
/// All maps are keyed by 3-char ISO 639-2 language code (e.g.
|
||||
/// `"eng"`, `"fra"`, `"jpn"`) — the same key segment used in the
|
||||
/// `bdmt_<lang>.xml` filename.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct DiscMetadata {
|
||||
/// Localized titles, keyed by 3-char ISO 639-2 lang code
|
||||
/// (e.g. "eng" → "Dune Part Two")
|
||||
pub titles: BTreeMap<String, String>,
|
||||
/// First-line / short description, per lang
|
||||
pub descriptions: BTreeMap<String, String>,
|
||||
/// Disc N of M for box sets (None if not a box set)
|
||||
pub disc_number: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// True if `/BDMV/META/DL/` exists and contains at least one
|
||||
/// `bdmt_*.xml` file.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
let Some(dir) = udf.find_dir("/BDMV/META/DL") else {
|
||||
return false;
|
||||
};
|
||||
dir.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && is_bdmt_filename(&e.name))
|
||||
}
|
||||
|
||||
/// Read every `bdmt_<lang>.xml` under `/BDMV/META/DL/` and return the
|
||||
/// aggregated [`DiscMetadata`]. Returns `None` if no titles could be
|
||||
/// extracted from any file.
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<DiscMetadata> {
|
||||
let dir = udf.find_dir("/BDMV/META/DL")?;
|
||||
let mut out = DiscMetadata::default();
|
||||
|
||||
for entry in &dir.entries {
|
||||
if entry.is_dir {
|
||||
continue;
|
||||
}
|
||||
let Some(lang) = lang_code_from_filename(&entry.name) else {
|
||||
continue;
|
||||
};
|
||||
let path = format!("/BDMV/META/DL/{}", entry.name);
|
||||
let Ok(bytes) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::str::from_utf8(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let Some((title, description, disc_set)) = parse_bdmt_xml(&lang, text) else {
|
||||
continue;
|
||||
};
|
||||
out.titles.insert(lang.clone(), title);
|
||||
if let Some(desc) = description {
|
||||
out.descriptions.insert(lang.clone(), desc);
|
||||
}
|
||||
// Disc-set position is disc-global; first one we successfully
|
||||
// read wins. (All bdmt_*.xml on a given disc carry the same
|
||||
// value in practice.)
|
||||
if out.disc_number.is_none() {
|
||||
if let Some(ds) = disc_set {
|
||||
out.disc_number = Some(ds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out.titles.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `name` matches the `bdmt_<lang>.xml` convention with a
|
||||
/// 3-character ISO 639-2 lang code segment. Case-insensitive.
|
||||
fn is_bdmt_filename(name: &str) -> bool {
|
||||
lang_code_from_filename(name).is_some()
|
||||
}
|
||||
|
||||
/// Extract the 3-char language code from a `bdmt_<lang>.xml` filename.
|
||||
/// Returns `None` if the filename doesn't match. Lang code is
|
||||
/// lowercased so callers always see e.g. `"eng"` not `"ENG"`.
|
||||
fn lang_code_from_filename(name: &str) -> Option<String> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let stem = lower.strip_suffix(".xml")?;
|
||||
let lang = stem.strip_prefix("bdmt_")?;
|
||||
// ISO 639-2 codes are exactly 3 ASCII letters. Be strict — keeps
|
||||
// us from picking up unrelated `bdmt_foo.xml` siblings.
|
||||
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
Some(lang.to_string())
|
||||
}
|
||||
|
||||
/// Tuple returned by [`parse_bdmt_xml`]: `(title, description?, disc_set?)`.
|
||||
/// Aliased so the function signature isn't a clippy::type-complexity offender.
|
||||
pub(crate) type BdmtFields = (String, Option<String>, Option<(u32, u32)>);
|
||||
|
||||
/// Parse one `bdmt_<lang>.xml` document and return
|
||||
/// `(title, description?, disc_set?)`. Returns `None` if no title
|
||||
/// could be located — the caller treats this as "skip this file".
|
||||
///
|
||||
/// Title-element preference: `<di:name>` → `<di:title>` →
|
||||
/// `<di:tableOfContents>/<di:titleName>` (first match wins, per the
|
||||
/// authoring-tool conventions documented at the module level).
|
||||
pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option<BdmtFields> {
|
||||
let title = extract_title(xml_text)?;
|
||||
let description = xml::text(xml_text, "description")
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|s| !looks_like_xml(s))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let disc_set = extract_disc_set(xml_text);
|
||||
Some((title, description, disc_set))
|
||||
}
|
||||
|
||||
/// Reject candidate description strings that are themselves XML
|
||||
/// fragments — observed on disc-04 (Top Gun: Maverick), where
|
||||
/// `<di:description>` contained `<di:thumbnail href="…"/>` child
|
||||
/// elements and no actual prose. Surfacing that raw to the JSON
|
||||
/// output is worse than dropping the field entirely.
|
||||
fn looks_like_xml(s: &str) -> bool {
|
||||
let t = s.trim_start();
|
||||
t.starts_with('<')
|
||||
}
|
||||
|
||||
/// Try title-bearing element variants in priority order. The `xml`
|
||||
/// helpers are case- and namespace-insensitive, so callers pass the
|
||||
/// bare local name (no `di:` prefix).
|
||||
fn extract_title(xml_text: &str) -> Option<String> {
|
||||
// Order matches the module-level convention: <di:name> first
|
||||
// (Paramount-style), then <di:title>, then the nested
|
||||
// tableOfContents/titleName form.
|
||||
for tag in ["name", "title"] {
|
||||
if let Some(s) = xml::text(xml_text, tag) {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// tableOfContents/titleName: search inside the toc block so we
|
||||
// don't accidentally pick a stray <titleName> from elsewhere.
|
||||
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
|
||||
let block = &xml_text[s..e];
|
||||
if let Some(t) = xml::text(block, "titleName") {
|
||||
let trimmed = t.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract `(discNumber, numSets)` if both are present and parse as
|
||||
/// `u32`. Accepts either `<di:numSets>` or `<di:numberOfSets>` for
|
||||
/// the denominator (both forms appear in the wild).
|
||||
fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> {
|
||||
let n = xml::text(xml_text, "discNumber")?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
let total = xml::text(xml_text, "numSets")
|
||||
.or_else(|| xml::text(xml_text, "numberOfSets"))?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
Some((n, total))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_simple_title() {
|
||||
// Minimal Paramount-style document: <di:name> as the title
|
||||
// carrier inside a <discInfo> root.
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Part Two</di:name>
|
||||
</discInfo>"#;
|
||||
let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse");
|
||||
assert_eq!(title, "Dune Part Two");
|
||||
assert_eq!(desc, None);
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_title_element_variant() {
|
||||
// <di:title> is the alternate carrier; should be picked up
|
||||
// when <di:name> is absent.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:title>The Matrix</di:title>
|
||||
<di:description>A film about computers.</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "The Matrix");
|
||||
assert_eq!(desc.as_deref(), Some("A film about computers."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_title_from_table_of_contents_fallback() {
|
||||
// Some authoring tools nest the title under tableOfContents.
|
||||
// No <di:name> or <di:title> at top level → fall back to
|
||||
// titleName inside tableOfContents.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:tableOfContents>
|
||||
<di:titleName>Inside Out 2</di:titleName>
|
||||
</di:tableOfContents>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "Inside Out 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_position() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>LOTR Disc 2</di:name>
|
||||
<di:discNumber>2</di:discNumber>
|
||||
<di:numSets>5</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, Some((2, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_position_alternate_total_tag() {
|
||||
// <di:numberOfSets> is an alternate spelling we've seen.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>3</di:discNumber>
|
||||
<di:numberOfSets>6</di:numberOfSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, Some((3, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_requires_both_fields() {
|
||||
// discNumber alone (no total) yields None — we don't fabricate
|
||||
// a denominator.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_languages_keyed_correctly() {
|
||||
// Simulate driving parse_bdmt_xml from two synthetic XML
|
||||
// blobs and aggregating into DiscMetadata the same way parse()
|
||||
// would. This exercises the BTreeMap key handling without
|
||||
// needing a UdfFs.
|
||||
let eng_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Part Two</di:name>
|
||||
</discInfo>"#;
|
||||
let fra_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Deuxième Partie</di:name>
|
||||
<di:description>Suite du film de 2021.</di:description>
|
||||
</discInfo>"#;
|
||||
|
||||
let mut meta = DiscMetadata::default();
|
||||
for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] {
|
||||
let (title, desc, ds) = parse_bdmt_xml(lang, blob).unwrap();
|
||||
meta.titles.insert(lang.to_string(), title);
|
||||
if let Some(d) = desc {
|
||||
meta.descriptions.insert(lang.to_string(), d);
|
||||
}
|
||||
if meta.disc_number.is_none() {
|
||||
if let Some(d) = ds {
|
||||
meta.disc_number = Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
meta.titles.get("eng").map(String::as_str),
|
||||
Some("Dune Part Two")
|
||||
);
|
||||
assert_eq!(
|
||||
meta.titles.get("fra").map(String::as_str),
|
||||
Some("Dune Deuxième Partie")
|
||||
);
|
||||
assert!(meta.descriptions.get("eng").is_none());
|
||||
assert_eq!(
|
||||
meta.descriptions.get("fra").map(String::as_str),
|
||||
Some("Suite du film de 2021.")
|
||||
);
|
||||
assert_eq!(meta.disc_number, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_xml_returns_none() {
|
||||
// Random gibberish has no recognizable title element. We
|
||||
// document the contract: parse_bdmt_xml returns None, and
|
||||
// parse() (the caller) skips the file. Aggregating across
|
||||
// zero files leaves DiscMetadata::default() — which parse()
|
||||
// surfaces as None to its caller. Either is documented as
|
||||
// acceptable per the module spec.
|
||||
let bad = "this is not xml &&& <<< nope";
|
||||
assert!(parse_bdmt_xml("eng", bad).is_none());
|
||||
|
||||
// Half-open tag, no body, no close: also yields no title.
|
||||
let truncated = "<discInfo><di:name>";
|
||||
assert!(parse_bdmt_xml("eng", truncated).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_with_only_child_xml_is_dropped() {
|
||||
// Real-world bug from disc-04 (Top Gun: Maverick, 2026-05-11
|
||||
// capture): <di:description> contained only <di:thumbnail/>
|
||||
// child elements with no actual prose. The previous parser
|
||||
// surfaced the raw XML fragment as the description string.
|
||||
// Now we reject candidates that begin with `<`.
|
||||
let xml = r#"<discInfo>
|
||||
<di:name>Top Gun: Maverick</di:name>
|
||||
<di:description>
|
||||
<di:thumbnail href="tgm_meta_sm.jpg" />
|
||||
<di:thumbnail href="tgm_meta_lg.jpg" />
|
||||
</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, description, _) =
|
||||
parse_bdmt_xml("eng", xml).expect("title is present so parse must succeed");
|
||||
assert_eq!(title, "Top Gun: Maverick");
|
||||
assert!(
|
||||
description.is_none(),
|
||||
"description containing only XML children must be dropped, got {description:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_with_plain_text_passes_through() {
|
||||
// The legitimate case still works: a description with actual
|
||||
// prose survives the looks_like_xml filter.
|
||||
let xml = r#"<discInfo>
|
||||
<di:name>Some Movie</di:name>
|
||||
<di:description>An epic tale of one man's quest for tea.</di:description>
|
||||
</discInfo>"#;
|
||||
let (_, description, _) = parse_bdmt_xml("eng", xml).expect("must parse");
|
||||
assert_eq!(
|
||||
description.as_deref(),
|
||||
Some("An epic tale of one man's quest for tea.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_in_title_is_trimmed() {
|
||||
let xml = r#"<discInfo><di:name>
|
||||
Dune Part Two
|
||||
</di:name></discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "Dune Part Two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_code_extraction() {
|
||||
assert_eq!(lang_code_from_filename("bdmt_eng.xml"), Some("eng".into()));
|
||||
assert_eq!(lang_code_from_filename("BDMT_FRA.XML"), Some("fra".into()));
|
||||
assert_eq!(lang_code_from_filename("bdmt_jpn.xml"), Some("jpn".into()));
|
||||
// Non-matching cases:
|
||||
assert_eq!(lang_code_from_filename("bdmt_.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_engl.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_e1g.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None);
|
||||
assert_eq!(lang_code_from_filename("foo.xml"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! CLPI vs MPLS cross-validation diagnostic.
|
||||
//!
|
||||
//! Empirical question (raised 2026-05-11): is CLPI's per-stream
|
||||
//! language and codec data truly redundant with MPLS's STN-table data
|
||||
//! on real-world Blu-rays?
|
||||
//!
|
||||
//! Build a quick audit that walks both sources, normalizes their stream
|
||||
//! lists by (PID, language, coding_type), and flags any disagreement.
|
||||
//!
|
||||
//! Three classes of mismatch we want to detect:
|
||||
//!
|
||||
//! 1. **CLPI has streams MPLS doesn't reference.** Orphan streams in
|
||||
//! the .m2ts that no playlist's STN table includes. Means the user
|
||||
//! can't reach them through the menu but they're physically on the
|
||||
//! disc.
|
||||
//! 2. **MPLS has streams CLPI doesn't list.** Should never happen if
|
||||
//! both parsers are correct — playlists reference clips which
|
||||
//! reference streams. If it happens, one of our parsers has a bug.
|
||||
//! 3. **Same PID, different language / coding_type.** The playlist re-
|
||||
//! tagged a stream's metadata. Rare but spec-permitted. Means CLPI
|
||||
//! and MPLS disagree about the same physical stream's properties.
|
||||
//!
|
||||
//! If audits across the corpus show zero mismatches of any class, CLPI
|
||||
//! program_info extraction is **empirically redundant** for labels and
|
||||
//! we can leave it out of the registry. If even one mismatch surfaces,
|
||||
//! we add a CLPI label parser to the registry as belt-and-suspenders.
|
||||
//!
|
||||
//! This module exposes `audit(reader, udf)` returning a structured
|
||||
//! report. Surfaced via the labels-analyze tool — not part of the
|
||||
//! `analyze()` pipeline (no impact on the label output).
|
||||
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One row in the audit: a stream PID that's known to one source or
|
||||
/// both, with the fields each source reported.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClpiVsMplsRow {
|
||||
pub pid: u16,
|
||||
pub clpi_coding_type: Option<u8>,
|
||||
pub clpi_language: Option<String>,
|
||||
pub mpls_coding_type: Option<u8>,
|
||||
pub mpls_language: Option<String>,
|
||||
}
|
||||
|
||||
impl ClpiVsMplsRow {
|
||||
/// Three rules for classification:
|
||||
/// - both sources missing (impossible — caller wouldn't insert)
|
||||
/// - one source missing → class A or B (orphan-on-disc / playlist-only)
|
||||
/// - both present but fields differ → class C (metadata divergence)
|
||||
/// - both present and identical → no mismatch
|
||||
pub fn class(&self) -> ClpiVsMplsClass {
|
||||
match (
|
||||
self.clpi_coding_type.is_some(),
|
||||
self.mpls_coding_type.is_some(),
|
||||
) {
|
||||
(true, false) => ClpiVsMplsClass::ClpiOnly,
|
||||
(false, true) => ClpiVsMplsClass::MplsOnly,
|
||||
(true, true) => {
|
||||
let coding_match = self.clpi_coding_type == self.mpls_coding_type;
|
||||
let lang_match = self.clpi_language == self.mpls_language;
|
||||
if coding_match && lang_match {
|
||||
ClpiVsMplsClass::Match
|
||||
} else {
|
||||
ClpiVsMplsClass::Divergent
|
||||
}
|
||||
}
|
||||
(false, false) => ClpiVsMplsClass::Match,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification of one (PID) row.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClpiVsMplsClass {
|
||||
/// PID seen in CLPI ProgramInfo but no MPLS STN table references
|
||||
/// it. Orphan on disc.
|
||||
ClpiOnly,
|
||||
/// PID seen in MPLS STN table but no CLPI ProgramInfo includes it.
|
||||
/// One of our parsers probably has a bug.
|
||||
MplsOnly,
|
||||
/// Both sources see this PID with the same coding_type + language.
|
||||
Match,
|
||||
/// Both sources see this PID but disagree on coding_type or language.
|
||||
/// MPLS wins for label rendering (playlist-authoritative view); CLPI
|
||||
/// is the per-clip ground truth.
|
||||
Divergent,
|
||||
}
|
||||
|
||||
/// Full audit report.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClpiVsMplsAudit {
|
||||
pub rows: Vec<ClpiVsMplsRow>,
|
||||
}
|
||||
|
||||
impl ClpiVsMplsAudit {
|
||||
pub fn class_counts(&self) -> (usize, usize, usize, usize) {
|
||||
let mut clpi_only = 0;
|
||||
let mut mpls_only = 0;
|
||||
let mut matches = 0;
|
||||
let mut divergent = 0;
|
||||
for r in &self.rows {
|
||||
match r.class() {
|
||||
ClpiVsMplsClass::ClpiOnly => clpi_only += 1,
|
||||
ClpiVsMplsClass::MplsOnly => mpls_only += 1,
|
||||
ClpiVsMplsClass::Match => matches += 1,
|
||||
ClpiVsMplsClass::Divergent => divergent += 1,
|
||||
}
|
||||
}
|
||||
(clpi_only, mpls_only, matches, divergent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `/BDMV/CLIPINF/*.clpi` and `/BDMV/PLAYLIST/*.mpls`, build a
|
||||
/// dedup-by-PID table of (CLPI fields, MPLS fields), return the
|
||||
/// merged view. Missing files (read errors, parse failures) are
|
||||
/// silently skipped — this is diagnostic, not correctness-critical.
|
||||
pub fn audit(reader: &mut dyn SectorReader, udf: &UdfFs) -> ClpiVsMplsAudit {
|
||||
// Aggregate by PID across all CLPI files. If a PID appears in
|
||||
// multiple clips (typical — main movie clip + trailers reference
|
||||
// the same audio stream PIDs), first encountered wins (they should
|
||||
// all agree per BD spec).
|
||||
let mut clpi_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new();
|
||||
if let Some(dir) = udf.find_dir("/BDMV/CLIPINF") {
|
||||
let names: Vec<String> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".clpi"))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let path = format!("/BDMV/CLIPINF/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(clip) = crate::clpi::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
for s in clip.streams {
|
||||
clpi_by_pid
|
||||
.entry(s.pid)
|
||||
.or_insert((s.coding_type, s.language));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same for MPLS streams.
|
||||
let mut mpls_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new();
|
||||
if let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") {
|
||||
let names: Vec<String> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".mpls"))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pl) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
for s in pl.streams {
|
||||
if s.pid == 0 {
|
||||
// PID 0 means "no PID in stream entry" — skip rather
|
||||
// than collide with other entries.
|
||||
continue;
|
||||
}
|
||||
mpls_by_pid
|
||||
.entry(s.pid)
|
||||
.or_insert((s.coding_type, s.language));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge views: every PID seen anywhere gets a row.
|
||||
let mut all_pids: std::collections::BTreeSet<u16> = std::collections::BTreeSet::new();
|
||||
all_pids.extend(clpi_by_pid.keys().copied());
|
||||
all_pids.extend(mpls_by_pid.keys().copied());
|
||||
|
||||
let mut rows = Vec::with_capacity(all_pids.len());
|
||||
for pid in all_pids {
|
||||
let clpi = clpi_by_pid.get(&pid);
|
||||
let mpls = mpls_by_pid.get(&pid);
|
||||
rows.push(ClpiVsMplsRow {
|
||||
pid,
|
||||
clpi_coding_type: clpi.map(|(c, _)| *c),
|
||||
clpi_language: clpi.map(|(_, l)| l.clone()),
|
||||
mpls_coding_type: mpls.map(|(c, _)| *c),
|
||||
mpls_language: mpls.map(|(_, l)| l.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
ClpiVsMplsAudit { rows }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn class_match_when_identical() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("eng".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_clpi_only_when_mpls_missing() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: None,
|
||||
mpls_language: None,
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::ClpiOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_mpls_only_when_clpi_missing() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: None,
|
||||
clpi_language: None,
|
||||
mpls_coding_type: Some(0x90),
|
||||
mpls_language: Some("fra".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::MplsOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_divergent_on_lang_disagreement() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("und".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Divergent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_counts_sum_rows() {
|
||||
let audit = ClpiVsMplsAudit {
|
||||
rows: vec![
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("eng".into()),
|
||||
}, // Match
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1101,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("fra".into()),
|
||||
mpls_coding_type: None,
|
||||
mpls_language: None,
|
||||
}, // ClpiOnly
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1102,
|
||||
clpi_coding_type: None,
|
||||
clpi_language: None,
|
||||
mpls_coding_type: Some(0x90),
|
||||
mpls_language: Some("eng".into()),
|
||||
}, // MplsOnly
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1103,
|
||||
clpi_coding_type: Some(0x86),
|
||||
clpi_language: Some("spa".into()),
|
||||
mpls_coding_type: Some(0x86),
|
||||
mpls_language: Some("ita".into()),
|
||||
}, // Divergent
|
||||
],
|
||||
};
|
||||
let (co, mo, m, d) = audit.class_counts();
|
||||
assert_eq!(co, 1);
|
||||
assert_eq!(mo, 1);
|
||||
assert_eq!(m, 1);
|
||||
assert_eq!(d, 1);
|
||||
}
|
||||
}
|
||||
+206
-109
@@ -52,7 +52,7 @@
|
||||
//! (each enum value's `<init>` is called with its name string as
|
||||
//! the first arg).
|
||||
//!
|
||||
//! - **Phase C** — binding-class identification (`find_binding_class`).
|
||||
//! - **Phase C** — binding-class identification (`find_binding_classes`).
|
||||
//! The per-stream table is built by some class via repeated
|
||||
//! `getstatic` references to the master enums identified in A.
|
||||
//! That class has the highest such `getstatic` count in the jar.
|
||||
@@ -138,34 +138,41 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult>
|
||||
);
|
||||
}
|
||||
|
||||
// Phase C — find the binding class via getstatic count to
|
||||
// the master enums.
|
||||
let binding_class_name = find_binding_class(archive, &master_table.class_name_set());
|
||||
let Some(binding_class_name) = binding_class_name else {
|
||||
// Phase C — find ALL binding-class candidates (audio + subtitle
|
||||
// are often split across two classes on Deluxe). Each gets its
|
||||
// own `<clinit>` walk; constructions union into a single
|
||||
// stream list for interpret_streams.
|
||||
let binding_classes = find_binding_classes(archive, &master_table.class_name_set());
|
||||
if binding_classes.is_empty() {
|
||||
tracing::info!(
|
||||
jar = %entry_name,
|
||||
"deluxe: no binding class found (no class has enough getstatic refs to master enums)"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
tracing::info!(
|
||||
jar = %entry_name,
|
||||
binding_class = %binding_class_name,
|
||||
"deluxe binding class identified",
|
||||
);
|
||||
}
|
||||
for (name, count) in &binding_classes {
|
||||
tracing::info!(
|
||||
jar = %entry_name,
|
||||
binding_class = %name,
|
||||
getstatic_count = count,
|
||||
"deluxe binding class candidate",
|
||||
);
|
||||
}
|
||||
|
||||
// Phase D — decode the binding class's <clinit>.
|
||||
let streams = decode_binding(archive, &binding_class_name, &master_table);
|
||||
// Phase D — decode each binding class's <clinit>.
|
||||
let mut streams: Vec<Construction> = Vec::new();
|
||||
for (name, _) in &binding_classes {
|
||||
streams.extend(decode_binding(archive, name, &master_table));
|
||||
}
|
||||
if streams.is_empty() {
|
||||
tracing::info!(
|
||||
jar = %entry_name,
|
||||
binding_class = %binding_class_name,
|
||||
"deluxe: binding class found but produced 0 decoded streams"
|
||||
"deluxe: binding classes found but produced 0 decoded streams"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let labels = interpret_streams(&streams, &codec_table, &master_table);
|
||||
let labels = interpret_streams(&streams, &master_table);
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -545,26 +552,39 @@ fn clinit_news_and_ldcs(
|
||||
/// arity); we use a low floor (4) so a small disc with few streams
|
||||
/// still qualifies, but high enough to filter out classes that just
|
||||
/// reference the language enum once for a config string.
|
||||
pub(crate) fn find_binding_class(
|
||||
/// Identify all binding-class candidates by getstatic-count to the
|
||||
/// master enums. Some Deluxe discs split the per-stream table across
|
||||
/// two binding classes (one for audio, one for subtitle), so the
|
||||
/// per-stream decoder needs to walk all of them. Returns top-K
|
||||
/// candidates ordered by descending getstatic count, filtered to a
|
||||
/// minimum concentration of master-enum references.
|
||||
///
|
||||
/// Empirically (POC v0.3 dumps): on disc-01 the audio binding class
|
||||
/// (`ma.class`) has ~82 getstatic refs, the subtitle binding
|
||||
/// (`ko.class`) has ~63. Both share the master Language + Purpose
|
||||
/// enums.
|
||||
pub(crate) fn find_binding_classes(
|
||||
archive: &mut jar::Jar,
|
||||
master_enum_classes: &HashSet<&str>,
|
||||
) -> Option<String> {
|
||||
) -> Vec<(String, usize)> {
|
||||
const MIN_GETSTATIC: usize = 4;
|
||||
let mut best: Option<(String, usize)> = None;
|
||||
let mut candidates: Vec<(String, usize)> = Vec::new();
|
||||
jar::for_each_class(archive, |class_name, class| {
|
||||
let count = count_master_enum_getstatic(class, master_enum_classes);
|
||||
if count < MIN_GETSTATIC {
|
||||
return;
|
||||
}
|
||||
match &best {
|
||||
None => best = Some((class_name.to_string(), count)),
|
||||
Some((_, c)) if count > *c => {
|
||||
best = Some((class_name.to_string(), count));
|
||||
}
|
||||
_ => {}
|
||||
if count >= MIN_GETSTATIC {
|
||||
candidates.push((class_name.to_string(), count));
|
||||
}
|
||||
});
|
||||
best.map(|(name, _)| name)
|
||||
candidates.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
|
||||
// Top candidates only — anything significantly below the top one
|
||||
// is noise. We keep candidates whose count is at least 40% of the
|
||||
// top, capped at 4 total (audio + subtitle + future use).
|
||||
if let Some(top_count) = candidates.first().map(|(_, c)| *c) {
|
||||
let threshold = (top_count * 2) / 5; // 40%
|
||||
candidates.retain(|(_, c)| *c >= threshold);
|
||||
candidates.truncate(4);
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Count `getstatic` instructions in this class's `<clinit>` whose
|
||||
@@ -617,6 +637,15 @@ pub(crate) enum StackVal {
|
||||
kind: &'static str,
|
||||
ordinal: u16,
|
||||
},
|
||||
/// Reference to a `org.bluray.ti.CodingType` enum value. Field
|
||||
/// name (e.g. `DOLBY_AC3_AUDIO`, `DOLBY_LOSSLESS_AUDIO`) is the
|
||||
/// codec identifier. Deluxe binding constructors take a
|
||||
/// `LCodingType;` arg directly — codecs are NOT a Deluxe-internal
|
||||
/// enum (Phase B's codec-subclass walk was based on a wrong
|
||||
/// assumption; the actual codec source is the standard BD-J API
|
||||
/// enum). Discovered via deluxe-poc v0.3 binding-bytecode dump
|
||||
/// against disc-01 (Disney) + disc-09 (Warner) on 2026-05-10.
|
||||
CodingType(String),
|
||||
/// An uninitialized `new` object — popped by the matching
|
||||
/// invokespecial.
|
||||
NewObj(String),
|
||||
@@ -626,6 +655,10 @@ pub(crate) enum StackVal {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Fully-qualified class name of the BD-J spec codec enum that
|
||||
/// Deluxe constructors reference directly.
|
||||
const BD_CODING_TYPE_CLASS: &str = "org/bluray/ti/CodingType";
|
||||
|
||||
/// Phase D entry point: find the binding class in `archive`, run the
|
||||
/// bytecode walker against its `<clinit>`, return one `Construction`
|
||||
/// per `new X / invokespecial X.<init>` sequence.
|
||||
@@ -755,10 +788,19 @@ impl<'a> BindingDecoder<'a> {
|
||||
let val = insn
|
||||
.cp_index()
|
||||
.and_then(|i| self.pool.member_ref(i))
|
||||
.and_then(|m| {
|
||||
self.master
|
||||
.resolve(m.class_name, m.name)
|
||||
.map(|(kind, ord)| StackVal::EnumRef { kind, ordinal: ord })
|
||||
.map(|m| {
|
||||
// Three-way resolution:
|
||||
// 1. org.bluray.ti.CodingType.X → CodingType(X)
|
||||
// 2. master-enum classname.X → EnumRef(kind, ord)
|
||||
// 3. anything else → Unknown
|
||||
if m.class_name == BD_CODING_TYPE_CLASS {
|
||||
StackVal::CodingType(m.name.to_string())
|
||||
} else if let Some((kind, ord)) = self.master.resolve(m.class_name, m.name)
|
||||
{
|
||||
StackVal::EnumRef { kind, ordinal: ord }
|
||||
} else {
|
||||
StackVal::Unknown
|
||||
}
|
||||
})
|
||||
.unwrap_or(StackVal::Unknown);
|
||||
self.stack.push(val);
|
||||
@@ -946,48 +988,37 @@ impl MasterEnumTable {
|
||||
// ── interpret_streams: Constructions → StreamLabels ─────────────────────────
|
||||
|
||||
/// Convert the per-construction tuples from Phase D into
|
||||
/// [`StreamLabel`]s using a documented heuristic.
|
||||
/// [`StreamLabel`]s. Pattern verified against corpus discs via
|
||||
/// deluxe-poc v0.3 binding-bytecode dump (2026-05-10):
|
||||
///
|
||||
/// Heuristic:
|
||||
/// - For each Construction, classify the args into language / codec /
|
||||
/// purpose / region / int slots using the EnumRef kinds.
|
||||
/// - A construction with a Language ref AND a Codec ref → Audio stream.
|
||||
/// - A construction with a Language ref AND no Codec ref → Subtitle.
|
||||
/// - Any construction with no Language ref → ignored (not a stream).
|
||||
/// - stream_number = sequential per type (1, 2, 3 ...).
|
||||
/// - language = vocab::lang fallback applied to the enum value
|
||||
/// string (so "Brazilian Portuguese" → "por" + variant="Brazilian").
|
||||
/// When the enum value isn't a recognized phrase, the raw value is
|
||||
/// used as language verbatim (ISO codes pass through).
|
||||
/// - codec_hint = the CodecTable lookup of the codec ordinal.
|
||||
/// - purpose = LabelPurpose decoded from the Purpose-enum value
|
||||
/// string via the documented Deluxe Purpose enum (Normal,
|
||||
/// Commentary, PiP, Trivia, Descriptive, Score, NoForced,
|
||||
/// NoForcedDescriptive).
|
||||
/// - qualifier = LabelQualifier::Forced when the Purpose-enum value
|
||||
/// is one of the *Forced* variants (Normal+Forced bit etc.); else
|
||||
/// None. (Deluxe's framework doesn't appear to expose SDH at this
|
||||
/// layer — that comes from the subtitle codec.)
|
||||
/// Disney binding (5-arg): `BindingType.<init>(I, Lbe;, Llp;, I, LCodingType;)V`
|
||||
/// Warner binding (4-arg): `BindingType.<init>(I, Law;, Lgp;, LCodingType;)V`
|
||||
///
|
||||
/// This heuristic is the part of Phase D that needs corpus-disc
|
||||
/// verification. The DECODING (Constructions from bytecode) is
|
||||
/// mechanically correct; the SEMANTIC INTERPRETATION here may need
|
||||
/// adjustment once we see real binding-class output.
|
||||
fn interpret_streams(
|
||||
constructions: &[Construction],
|
||||
codec_table: &CodecTable,
|
||||
master: &MasterEnumTable,
|
||||
) -> Vec<StreamLabel> {
|
||||
/// Args are identified by **TYPE**, not position:
|
||||
/// - First `EnumRef{kind: "Language"}` → audio/subtitle language
|
||||
/// - First `EnumRef{kind: "Purpose"}` → Deluxe purpose ordinal
|
||||
/// - First `CodingType(name)` → codec field name (translated via
|
||||
/// [`coding_type_to_codec_hint`])
|
||||
/// - First `Int(n)` → stream index (preserved as ordering hint;
|
||||
/// per-type sequential stream_number is what actually goes into
|
||||
/// the StreamLabel, since BD spec stream-numbering is anchored on
|
||||
/// MPLS data, not the binding code)
|
||||
///
|
||||
/// Stream type inference:
|
||||
/// - Construction has a `CodingType` arg → audio stream (subtitles
|
||||
/// on Deluxe don't carry a CodingType; their codec is implicit
|
||||
/// PGS via the BD spec).
|
||||
/// - Construction has Language but no CodingType → subtitle stream.
|
||||
/// - No Language → not a stream (skip).
|
||||
fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -> Vec<StreamLabel> {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
let mut out = Vec::new();
|
||||
|
||||
for c in constructions {
|
||||
// Collect typed args: language / purpose ordinals + any int
|
||||
// (preserved for future stream-index resolution; logged only
|
||||
// for now).
|
||||
let mut lang_ord: Option<u16> = None;
|
||||
let mut purpose_ord: Option<u16> = None;
|
||||
let mut coding_type: Option<String> = None;
|
||||
let mut stream_idx_hint: Option<i32> = None;
|
||||
for arg in &c.args {
|
||||
match arg {
|
||||
@@ -996,31 +1027,28 @@ fn interpret_streams(
|
||||
"Purpose" => purpose_ord = purpose_ord.or(Some(*ordinal)),
|
||||
_ => {}
|
||||
},
|
||||
StackVal::Int(n) => stream_idx_hint = stream_idx_hint.or(Some(*n)),
|
||||
StackVal::CodingType(name) => {
|
||||
coding_type = coding_type.or_else(|| Some(name.clone()));
|
||||
}
|
||||
StackVal::Int(n) => {
|
||||
stream_idx_hint = stream_idx_hint.or(Some(*n));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// The Codec enum isn't fingerprinted by ldc prefix (0 ldcs
|
||||
// in its <clinit>), so it isn't in MasterEnumTable.
|
||||
// EnumRefs resolved through master_table never carry the
|
||||
// "Codec" kind. Codec association at this layer is heuristic:
|
||||
// if the construction's binding_type matches one of the
|
||||
// CodecTable entries (substring match), use that codec.
|
||||
// Stronger codec→ordinal resolution is deferred until corpus
|
||||
// bytecode confirms the codec field's location in the
|
||||
// binding constructor's args.
|
||||
let codec_hint = codec_table
|
||||
.codecs
|
||||
.iter()
|
||||
.find(|name| !name.is_empty() && c.binding_type.contains(name.as_str()))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let Some(lang_ord) = lang_ord else { continue };
|
||||
|
||||
// Audio when codec is known, subtitle otherwise.
|
||||
let (stream_type, stream_number) = if !codec_hint.is_empty() {
|
||||
// Audio when a CodingType is present (audio binding type
|
||||
// always references org.bluray.ti.CodingType); subtitle
|
||||
// otherwise.
|
||||
let codec_hint = coding_type
|
||||
.as_deref()
|
||||
.map(coding_type_to_codec_hint)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_default();
|
||||
|
||||
let (stream_type, stream_number) = if coding_type.is_some() {
|
||||
audio_idx += 1;
|
||||
(StreamLabelType::Audio, audio_idx)
|
||||
} else {
|
||||
@@ -1044,10 +1072,10 @@ fn interpret_streams(
|
||||
|
||||
if let Some(hint) = stream_idx_hint {
|
||||
tracing::debug!(
|
||||
stream_idx_hint = hint,
|
||||
disc_stream_idx = hint,
|
||||
lang = %language,
|
||||
binding = %c.binding_type,
|
||||
"deluxe interpret_streams: captured int arg for future stream-index resolution"
|
||||
"deluxe interpret_streams: disc-authored stream index (not used for stream_number; preserved for diagnostic)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1066,6 +1094,39 @@ fn interpret_streams(
|
||||
out
|
||||
}
|
||||
|
||||
/// Map a `org.bluray.ti.CodingType` field name (as observed in
|
||||
/// getstatic operands on Deluxe binding classes) to a human-readable
|
||||
/// codec hint string.
|
||||
///
|
||||
/// CodingType is the standard BD-J API enum; values are documented
|
||||
/// in the BD-J specification and verified empirically against the
|
||||
/// binding-bytecode dumps in `(internal)/research/deluxe-poc/data/`.
|
||||
/// Unknown field names pass through unchanged so unfamiliar codecs
|
||||
/// still surface something rather than going silent.
|
||||
fn coding_type_to_codec_hint(field: &str) -> &str {
|
||||
match field {
|
||||
// Lossless / hi-res.
|
||||
"DOLBY_LOSSLESS_AUDIO" => "Dolby TrueHD",
|
||||
"DTS_HD_LOSSLESS_AUDIO" | "DTS_HD_MA_AUDIO" => "DTS-HD Master Audio",
|
||||
"LPCM_AUDIO" => "LPCM",
|
||||
// Dolby family.
|
||||
"DOLBY_AC3_AUDIO" => "Dolby Digital",
|
||||
"DOLBY_DIGITAL_PLUS_AUDIO" => "Dolby Digital Plus",
|
||||
"DOLBY_ATMOS_AUDIO" => "Dolby Atmos",
|
||||
// DTS family.
|
||||
"DTS_AUDIO" => "DTS",
|
||||
"DTS_HD_AUDIO" | "DTS_HD_HR_AUDIO" => "DTS-HD HR",
|
||||
// MPEG family.
|
||||
"MPEG1_AUDIO_LAYER2" | "MPEG2_AUDIO_LAYER2" => "MPEG Audio",
|
||||
// PG-style subtitle codecs (rare to see in Deluxe bindings;
|
||||
// subtitles usually have NO CodingType arg).
|
||||
"PG_STREAM" | "PRESENTATION_GRAPHICS_STREAM" => "PGS",
|
||||
// Unknown / future — pass through verbatim so the operator
|
||||
// can see what the disc actually authored.
|
||||
_ => field,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deluxe Purpose enum ordinal → (LabelPurpose, LabelQualifier). The
|
||||
/// enum order is fixed per Phase A's verified output:
|
||||
/// 0=Normal, 1=Commentary, 2=PiP, 3=Trivia, 4=Descriptive, 5=Score,
|
||||
@@ -1419,9 +1480,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_streams_emits_subtitle_when_no_codec() {
|
||||
// A Construction with just a language enum ref + no codec
|
||||
// → subtitle stream (codec_hint stays empty).
|
||||
fn interpret_streams_emits_subtitle_when_no_codingtype() {
|
||||
// A Construction with just a language enum ref (no CodingType)
|
||||
// -> subtitle stream (codec_hint stays empty). Subtitles on
|
||||
// Deluxe don't carry a CodingType arg.
|
||||
let constructions = vec![Construction {
|
||||
binding_type: "SubtitleSlot".into(),
|
||||
args: vec![StackVal::EnumRef {
|
||||
@@ -1429,9 +1491,7 @@ mod tests {
|
||||
ordinal: 0,
|
||||
}],
|
||||
}];
|
||||
let codec_table = CodecTable::default();
|
||||
let master = lang_enum_master();
|
||||
let out = interpret_streams(&constructions, &codec_table, &master);
|
||||
let out = interpret_streams(&constructions, &lang_enum_master());
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(out[0].language, "eng");
|
||||
@@ -1439,24 +1499,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_streams_emits_audio_when_binding_matches_codec_name() {
|
||||
// A Construction whose binding_type contains a codec name
|
||||
// from CodecTable → audio stream with codec_hint populated.
|
||||
fn interpret_streams_emits_audio_when_codingtype_present() {
|
||||
// A Construction with a CodingType arg -> audio stream with
|
||||
// codec_hint populated by coding_type_to_codec_hint.
|
||||
let constructions = vec![Construction {
|
||||
binding_type: "AudioSlot_ATMOS".into(),
|
||||
args: vec![StackVal::EnumRef {
|
||||
kind: "Language",
|
||||
ordinal: 0,
|
||||
}],
|
||||
binding_type: "ng".into(),
|
||||
args: vec![
|
||||
StackVal::Int(1),
|
||||
StackVal::EnumRef {
|
||||
kind: "Language",
|
||||
ordinal: 0,
|
||||
},
|
||||
StackVal::EnumRef {
|
||||
kind: "Purpose",
|
||||
ordinal: 0,
|
||||
},
|
||||
StackVal::CodingType("DOLBY_LOSSLESS_AUDIO".into()),
|
||||
],
|
||||
}];
|
||||
let codec_table = CodecTable {
|
||||
codecs: vec!["ATMOS".into(), "DTS".into()],
|
||||
};
|
||||
let master = lang_enum_master();
|
||||
let out = interpret_streams(&constructions, &codec_table, &master);
|
||||
let out = interpret_streams(&constructions, &lang_enum_master());
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(out[0].codec_hint, "ATMOS");
|
||||
assert_eq!(out[0].codec_hint, "Dolby TrueHD");
|
||||
assert_eq!(out[0].language, "eng");
|
||||
}
|
||||
|
||||
@@ -1475,7 +1539,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
}];
|
||||
let out = interpret_streams(&constructions, &CodecTable::default(), &lang_enum_master());
|
||||
let out = interpret_streams(&constructions, &lang_enum_master());
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
@@ -1486,10 +1550,43 @@ mod tests {
|
||||
binding_type: "SomeOtherType".into(),
|
||||
args: vec![StackVal::Int(1)],
|
||||
}];
|
||||
let out = interpret_streams(&constructions, &CodecTable::default(), &lang_enum_master());
|
||||
let out = interpret_streams(&constructions, &lang_enum_master());
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coding_type_maps_known_codecs() {
|
||||
// BD-J spec CodingType field names -> display strings.
|
||||
assert_eq!(
|
||||
coding_type_to_codec_hint("DOLBY_LOSSLESS_AUDIO"),
|
||||
"Dolby TrueHD"
|
||||
);
|
||||
assert_eq!(
|
||||
coding_type_to_codec_hint("DOLBY_AC3_AUDIO"),
|
||||
"Dolby Digital"
|
||||
);
|
||||
assert_eq!(
|
||||
coding_type_to_codec_hint("DOLBY_DIGITAL_PLUS_AUDIO"),
|
||||
"Dolby Digital Plus"
|
||||
);
|
||||
assert_eq!(coding_type_to_codec_hint("DTS_AUDIO"), "DTS");
|
||||
assert_eq!(
|
||||
coding_type_to_codec_hint("DTS_HD_MA_AUDIO"),
|
||||
"DTS-HD Master Audio"
|
||||
);
|
||||
assert_eq!(coding_type_to_codec_hint("LPCM_AUDIO"), "LPCM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coding_type_passes_through_unknown() {
|
||||
// Unknown field names pass through verbatim so the operator
|
||||
// sees what the disc authored.
|
||||
assert_eq!(
|
||||
coding_type_to_codec_hint("FUTURE_CODEC_X"),
|
||||
"FUTURE_CODEC_X"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codec_name_picks_uppercase_with_underscore() {
|
||||
// Synthetic class file built via ClassFile::parse would be
|
||||
|
||||
+513
-14
@@ -7,14 +7,18 @@
|
||||
//! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
|
||||
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
|
||||
|
||||
mod bdmt;
|
||||
pub(crate) mod class_reader;
|
||||
pub mod clpi_audit;
|
||||
mod criterion;
|
||||
mod ctrm;
|
||||
mod dbp;
|
||||
mod deluxe;
|
||||
pub(crate) mod jar;
|
||||
mod mpls_universal;
|
||||
mod paramount;
|
||||
mod pixelogic;
|
||||
mod png_filenames;
|
||||
pub(crate) mod text;
|
||||
pub mod vocab;
|
||||
pub(crate) mod xml;
|
||||
@@ -23,6 +27,11 @@ use crate::disc::{DiscTitle, Stream};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
// Re-export bdmt's public type so callers can construct/inspect
|
||||
// disc-level metadata via `labels::DiscMetadata`. The module itself
|
||||
// stays private — analyze() drives the parse path.
|
||||
pub use bdmt::DiscMetadata;
|
||||
|
||||
// Re-exported via crate::disc — the public API surfaces these next to
|
||||
// AudioStream/SubtitleStream so callers can map purpose/qualifier to display
|
||||
// text in their own locale.
|
||||
@@ -49,7 +58,7 @@ pub struct StreamLabel {
|
||||
pub variant: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum StreamLabelType {
|
||||
Audio,
|
||||
Subtitle,
|
||||
@@ -88,10 +97,14 @@ type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<ParseResult>;
|
||||
/// A parser SHOULD return `High` only when its full schema was
|
||||
/// extracted with no fallback or guessing. `Medium` is for matched-
|
||||
/// but-degraded outputs (some streams missing fields, fingerprint
|
||||
/// matched but a sub-table couldn't be decoded, etc.). The registry
|
||||
/// prefers `High` over `Medium`; ties fall to array order.
|
||||
/// matched but a sub-table couldn't be decoded, etc.). `Low` is for
|
||||
/// the universal MPLS fallback — spec-mandated stream metadata
|
||||
/// (language + base codec) that's correct but lacks editorial labels
|
||||
/// (commentary, SDH, etc.). The registry prefers `High > Medium > Low`;
|
||||
/// ties fall to array order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Confidence {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
@@ -124,6 +137,16 @@ impl ParseResult {
|
||||
confidence: Confidence::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience for the universal MPLS fallback: spec-derived
|
||||
/// stream language + codec, but no editorial labels (commentary,
|
||||
/// SDH, etc.). Framework parsers always win over `low`.
|
||||
pub fn low(labels: Vec<StreamLabel>) -> Self {
|
||||
ParseResult {
|
||||
labels,
|
||||
confidence: Confidence::Low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
@@ -138,6 +161,17 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
// (constant-pool iteration vs. deluxe's bytecode walking).
|
||||
("dbp", dbp::detect, dbp::parse),
|
||||
("deluxe", deluxe::detect, deluxe::parse),
|
||||
// Universal MPLS fallback. Returns Confidence::Low so framework
|
||||
// parsers always win when they match. Closes the "no framework
|
||||
// matched" gap (e.g. HDMV-only discs) with spec-derived language
|
||||
// + base codec for every stream the playlist references. Runs
|
||||
// last in registry order so it's only the chosen parser when
|
||||
// nothing else fired.
|
||||
(
|
||||
"mpls_universal",
|
||||
mpls_universal::detect,
|
||||
mpls_universal::parse,
|
||||
),
|
||||
];
|
||||
|
||||
/// Search disc for config files, extract labels, apply to streams.
|
||||
@@ -357,21 +391,213 @@ fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((name, r)) => {
|
||||
let (name, mut labels) = match best {
|
||||
Some((n, r)) => {
|
||||
tracing::info!(
|
||||
parser = name,
|
||||
parser = n,
|
||||
confidence = ?r.confidence,
|
||||
label_count = r.labels.len(),
|
||||
"label parser selected",
|
||||
);
|
||||
r.labels
|
||||
(n, r.labels)
|
||||
}
|
||||
None => {
|
||||
tracing::info!("no label parser matched");
|
||||
Vec::new()
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
// Gap-fill: framework parsers often under-yield on multi-track
|
||||
// discs because their authoring layer only ships editorial labels
|
||||
// for "interesting" streams (Director's Cut, Atmos, SDH) and
|
||||
// leaves the rest as plain numbered slots. MPLS sees all streams
|
||||
// the playlist references. If MPLS has entries the framework
|
||||
// didn't cover (by stream_type + stream_number), merge them in
|
||||
// so the user sees every track even when only the "interesting"
|
||||
// ones have editorial names. Skips the merge when mpls_universal
|
||||
// was itself the chosen parser (its labels ARE the labels).
|
||||
if name != "mpls_universal" {
|
||||
if let Some(mpls_result) = mpls_universal::parse(reader, udf) {
|
||||
fill_gaps_from_mpls(&mut labels, &mpls_result.labels);
|
||||
}
|
||||
}
|
||||
|
||||
// CLPI orphan streams: PIDs in /BDMV/CLIPINF/*.clpi ProgramInfo
|
||||
// that no MPLS playlist references. Empirical (2026-05-11): ~5%
|
||||
// of streams across the 11-disc corpus are CLPI-only — physically
|
||||
// on disc, not menu-reachable. Append them as Low-confidence
|
||||
// labels at the tail of each stream_type (next slot after the
|
||||
// highest existing stream_number).
|
||||
let _orphans_added = append_clpi_orphans(&mut labels, reader, udf);
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
/// Append entries from `mpls` whose `(stream_type, stream_number)`
|
||||
/// isn't already represented in `framework`. Framework labels are
|
||||
/// richer (editorial purpose/qualifier, codec_hint with object-audio
|
||||
/// detail like Atmos) so they always win for slots they cover; MPLS
|
||||
/// only fills in untaken slots. Stable sort by (type, number) at
|
||||
/// the end so callers see a deterministic, ascending list.
|
||||
fn fill_gaps_from_mpls(framework: &mut Vec<StreamLabel>, mpls: &[StreamLabel]) {
|
||||
use std::collections::HashSet;
|
||||
let covered: HashSet<(StreamLabelType, u16)> = framework
|
||||
.iter()
|
||||
.map(|l| (l.stream_type, l.stream_number))
|
||||
.collect();
|
||||
let mut added = 0usize;
|
||||
for m in mpls {
|
||||
if !covered.contains(&(m.stream_type, m.stream_number)) {
|
||||
framework.push(m.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
if added > 0 {
|
||||
tracing::info!(
|
||||
gap_fill_added = added,
|
||||
"MPLS gap-fill merged streams the framework parser left uncovered"
|
||||
);
|
||||
framework.sort_by_key(|l| (type_tag(l.stream_type), l.stream_number));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable sort key for `StreamLabelType`. Audio < Subtitle so the
|
||||
/// merged label list groups audios first then subtitles.
|
||||
fn type_tag(t: StreamLabelType) -> u8 {
|
||||
match t {
|
||||
StreamLabelType::Audio => 0,
|
||||
StreamLabelType::Subtitle => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append CLPI ProgramInfo streams that NO existing label covers by
|
||||
/// PID. These are "orphan" streams — physically present in the .m2ts
|
||||
/// per CLPI's clip-authoritative view, but no MPLS playlist references
|
||||
/// them, so the framework + MPLS gap-fill missed them. Returns the
|
||||
/// number of orphans appended.
|
||||
///
|
||||
/// Numbering: the new entries get `stream_number = max(existing
|
||||
/// per type) + 1, +2, …` so the playlist-reachable streams keep their
|
||||
/// original positions and orphans sort cleanly at the tail. Empirically
|
||||
/// these are commentary or alternate-version streams that the
|
||||
/// authoring tool left out of the published playlist.
|
||||
fn append_clpi_orphans(
|
||||
labels: &mut Vec<StreamLabel>,
|
||||
reader: &mut dyn SectorReader,
|
||||
udf: &UdfFs,
|
||||
) -> usize {
|
||||
// Index existing labels by PID — but StreamLabel doesn't carry
|
||||
// PID. Index by (type, language, codec_hint) tuple instead; this
|
||||
// is fuzzier than PID matching but the only signal available
|
||||
// here. False positives (a CLPI orphan that happens to share
|
||||
// (type, lang, codec) with an MPLS stream we already have) are
|
||||
// benign — we just skip the duplicate. False negatives (rare)
|
||||
// would cause double-listing, which is the conservative failure
|
||||
// mode.
|
||||
use std::collections::HashSet;
|
||||
let existing: HashSet<(StreamLabelType, String, String)> = labels
|
||||
.iter()
|
||||
.map(|l| (l.stream_type, l.language.clone(), l.codec_hint.clone()))
|
||||
.collect();
|
||||
|
||||
// Walk CLPI files, collect distinct (type, pid, coding_type, lang)
|
||||
// tuples not already in `existing`. Dedup by PID across files so
|
||||
// a stream appearing in two clips only gets added once.
|
||||
let Some(dir) = udf.find_dir("/BDMV/CLIPINF") else {
|
||||
return 0;
|
||||
};
|
||||
let names: Vec<String> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".clpi"))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
let mut seen_pids: HashSet<u16> = HashSet::new();
|
||||
let mut candidates: Vec<(StreamLabelType, u16, u8, String)> = Vec::new();
|
||||
for name in names {
|
||||
let path = format!("/BDMV/CLIPINF/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(clip) = crate::clpi::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
for s in clip.streams {
|
||||
if !seen_pids.insert(s.pid) {
|
||||
continue;
|
||||
}
|
||||
// Translate CLPI coding_type → label stream_type.
|
||||
let stype = match s.coding_type {
|
||||
0x80..=0x86 | 0xA1 | 0xA2 => StreamLabelType::Audio,
|
||||
0x90 | 0x91 => StreamLabelType::Subtitle,
|
||||
_ => continue, // video / unknown — skip
|
||||
};
|
||||
// Same dedup logic as MPLS: normalize language, build codec
|
||||
// hint, check against existing label set.
|
||||
let lang_norm = s.language.trim().to_ascii_lowercase();
|
||||
let codec_hint = mpls_universal::codec_name(s.coding_type).to_string();
|
||||
if existing.contains(&(stype, lang_norm.clone(), codec_hint.clone())) {
|
||||
continue;
|
||||
}
|
||||
candidates.push((stype, s.pid, s.coding_type, lang_norm));
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find next available stream_number per type.
|
||||
let mut next_audio: u16 = labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.map(|l| l.stream_number)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
let mut next_sub: u16 = labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
|
||||
.map(|l| l.stream_number)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
|
||||
let added = candidates.len();
|
||||
for (stype, _pid, coding_type, language) in candidates {
|
||||
let codec_hint = mpls_universal::codec_name(coding_type).to_string();
|
||||
let name = mpls_universal::language_display_name(&language);
|
||||
let stream_number = match stype {
|
||||
StreamLabelType::Audio => {
|
||||
let n = next_audio;
|
||||
next_audio += 1;
|
||||
n
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
let n = next_sub;
|
||||
next_sub += 1;
|
||||
n
|
||||
}
|
||||
};
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: stype,
|
||||
language,
|
||||
name,
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint,
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
if added > 0 {
|
||||
tracing::info!(
|
||||
clpi_orphans_added = added,
|
||||
"CLPI-only streams appended (PIDs not referenced by any MPLS playlist)"
|
||||
);
|
||||
labels.sort_by_key(|l| (type_tag(l.stream_type), l.stream_number));
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Diagnostic introspection — returns the parser that matched, the
|
||||
@@ -412,11 +638,29 @@ pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis {
|
||||
.then(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let (parser, confidence, labels) = match chosen {
|
||||
let (parser, confidence, mut labels) = match chosen {
|
||||
Some((name, r)) => (Some(*name), Some(r.confidence), r.labels.clone()),
|
||||
None => (None, None, Vec::new()),
|
||||
};
|
||||
|
||||
// Gap-fill: same merge as `extract()`. Framework labels (when
|
||||
// present) win for the slots they cover; MPLS fills in uncovered
|
||||
// stream_numbers. Skipped when MPLS was itself the chosen parser
|
||||
// (no gaps to fill against itself).
|
||||
let gap_fill_added = if parser.is_some() && parser != Some("mpls_universal") {
|
||||
let before = labels.len();
|
||||
// Re-run MPLS unconditionally — we only ran framework parsers
|
||||
// above (we want to know which one to pick), and in the
|
||||
// common case where MPLS would have detected but wasn't
|
||||
// chosen we still need its labels for the merge.
|
||||
if let Some(mpls_result) = mpls_universal::parse(reader, udf) {
|
||||
fill_gaps_from_mpls(&mut labels, &mpls_result.labels);
|
||||
}
|
||||
labels.len().saturating_sub(before)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if parsers_detected.is_empty() {
|
||||
tracing::info!("no label parser matched");
|
||||
} else if parser.is_none() {
|
||||
@@ -426,15 +670,79 @@ pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis {
|
||||
);
|
||||
}
|
||||
|
||||
// bdmt runs independently of the parser registry: it's disc-level
|
||||
// metadata (localized titles, box-set position), not per-stream
|
||||
// labels, so the "highest confidence wins" logic doesn't apply.
|
||||
// Always run if detected; surface result as a separate field.
|
||||
let disc_metadata = if bdmt::detect(udf) {
|
||||
bdmt::parse(reader, udf)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let chapter_summary = collect_chapter_summary(reader, udf);
|
||||
|
||||
LabelAnalysis {
|
||||
parser,
|
||||
parsers_detected,
|
||||
confidence,
|
||||
jar_inventory: inventory,
|
||||
labels,
|
||||
disc_metadata,
|
||||
gap_fill_added,
|
||||
chapter_summary,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan `/BDMV/PLAYLIST/*.mpls`, parse each, return a row per playlist
|
||||
/// with chapter count (mark_type ≤ 1) and total duration. Sorted by
|
||||
/// playlist filename. Skipped entries (read error, parse error, no
|
||||
/// marks) silently dropped — this is a diagnostic field, not a
|
||||
/// correctness-critical one.
|
||||
fn collect_chapter_summary(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<ChapterSummary> {
|
||||
let Some(playlist_dir) = udf.find_dir("/BDMV/PLAYLIST") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names: Vec<String> = playlist_dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".mpls"))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
names.sort();
|
||||
|
||||
let mut out: Vec<ChapterSummary> = Vec::new();
|
||||
for name in names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(playlist) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
let chapter_count = playlist.marks.iter().filter(|m| m.mark_type <= 1).count();
|
||||
if chapter_count == 0 {
|
||||
continue;
|
||||
}
|
||||
// Duration: sum of (out_time - in_time) across play items,
|
||||
// each in 45kHz PTS ticks → seconds. Approximates the disc
|
||||
// module's per-title duration; we don't claim sample accuracy
|
||||
// here, just enough to identify "the long one" (main movie).
|
||||
let duration_ticks: u64 = playlist
|
||||
.play_items
|
||||
.iter()
|
||||
.map(|pi| pi.out_time.saturating_sub(pi.in_time) as u64)
|
||||
.sum();
|
||||
let duration_secs = duration_ticks as f64 / 45000.0;
|
||||
out.push(ChapterSummary {
|
||||
playlist: name,
|
||||
chapter_count,
|
||||
duration_secs,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Result of [`analyze`].
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -461,6 +769,30 @@ pub struct LabelAnalysis {
|
||||
/// Raw labels emitted by the selected parser (empty if `parser`
|
||||
/// is `None`).
|
||||
pub labels: Vec<StreamLabel>,
|
||||
/// Disc-level metadata from `/BDMV/META/DL/bdmt_*.xml` if present.
|
||||
/// Localized title names, descriptions, box-set position. Orthogonal
|
||||
/// to per-stream labels; populated independently from the parser
|
||||
/// registry.
|
||||
pub disc_metadata: Option<bdmt::DiscMetadata>,
|
||||
/// Number of stream slots the MPLS gap-fill merge added on top of
|
||||
/// the framework parser's output. 0 means the framework covered
|
||||
/// every MPLS-known stream slot, or MPLS itself was the chosen
|
||||
/// parser. Diagnostic for the labels-analyze tool.
|
||||
pub gap_fill_added: usize,
|
||||
/// Per-playlist chapter summary: `(playlist_filename, chapter_count, duration_secs)`.
|
||||
/// Sourced from MPLS PlaylistMark entries with `mark_type ≤ 1`
|
||||
/// (chapter entries). Ordered by playlist filename. Empty if no
|
||||
/// MPLS files have parseable marks, or the disc isn't Blu-ray.
|
||||
pub chapter_summary: Vec<ChapterSummary>,
|
||||
}
|
||||
|
||||
/// One row of the per-playlist chapter summary in `LabelAnalysis`.
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChapterSummary {
|
||||
pub playlist: String,
|
||||
pub chapter_count: usize,
|
||||
pub duration_secs: f64,
|
||||
}
|
||||
|
||||
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
|
||||
@@ -544,12 +876,14 @@ mod registry_tests {
|
||||
"pixelogic",
|
||||
"ctrm",
|
||||
"dbp",
|
||||
"deluxe"
|
||||
"deluxe",
|
||||
"mpls_universal",
|
||||
],
|
||||
"PARSERS array order changed — confirm dbp + deluxe stay last \
|
||||
(loose detect, real check in parse), and stricter parsers \
|
||||
(paramount/criterion/pixelogic/ctrm — all file-presence \
|
||||
gated detect) stay first."
|
||||
"PARSERS array order changed — confirm dbp + deluxe stay just \
|
||||
before mpls_universal (loose detect, real check in parse), \
|
||||
stricter parsers (paramount/criterion/pixelogic/ctrm — all \
|
||||
file-presence gated detect) stay first, and mpls_universal \
|
||||
stays LAST as the universal Low-confidence fallback."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -571,6 +905,171 @@ mod registry_tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── gap_fill_from_mpls tests ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod gap_fill_tests {
|
||||
use super::*;
|
||||
|
||||
fn label(t: StreamLabelType, n: u16, lang: &str, codec: &str) -> StreamLabel {
|
||||
StreamLabel {
|
||||
stream_number: n,
|
||||
stream_type: t,
|
||||
language: lang.into(),
|
||||
name: String::new(),
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: codec.into(),
|
||||
variant: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_framework_takes_all_mpls() {
|
||||
let mut framework: Vec<StreamLabel> = Vec::new();
|
||||
let mpls = vec![
|
||||
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
|
||||
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
|
||||
label(StreamLabelType::Subtitle, 1, "eng", "PG"),
|
||||
];
|
||||
fill_gaps_from_mpls(&mut framework, &mpls);
|
||||
assert_eq!(framework.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framework_covers_all_mpls_no_op() {
|
||||
let mut framework = vec![
|
||||
label(StreamLabelType::Audio, 1, "eng", "Atmos"),
|
||||
label(StreamLabelType::Audio, 2, "fra", "Atmos"),
|
||||
];
|
||||
let mpls = vec![
|
||||
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
|
||||
label(StreamLabelType::Audio, 2, "fra", "TrueHD"),
|
||||
];
|
||||
fill_gaps_from_mpls(&mut framework, &mpls);
|
||||
assert_eq!(framework.len(), 2, "no gaps to fill");
|
||||
// Framework entries kept verbatim — richer codec_hint survives.
|
||||
assert_eq!(framework[0].codec_hint, "Atmos");
|
||||
assert_eq!(framework[1].codec_hint, "Atmos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_yield_fills_gaps_keeps_framework() {
|
||||
// Oppenheimer-style: framework matched but only labeled 2 of 6 audios.
|
||||
let mut framework = vec![
|
||||
label(StreamLabelType::Audio, 1, "eng", "Atmos"),
|
||||
label(StreamLabelType::Audio, 4, "eng", "Commentary"),
|
||||
label(StreamLabelType::Subtitle, 1, "eng", "PG SDH"),
|
||||
];
|
||||
let mpls = vec![
|
||||
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
|
||||
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
|
||||
label(StreamLabelType::Audio, 3, "spa", "AC-3"),
|
||||
label(StreamLabelType::Audio, 4, "eng", "AC-3"),
|
||||
label(StreamLabelType::Audio, 5, "deu", "AC-3"),
|
||||
label(StreamLabelType::Audio, 6, "ita", "AC-3"),
|
||||
label(StreamLabelType::Subtitle, 1, "eng", "PG"),
|
||||
label(StreamLabelType::Subtitle, 2, "fra", "PG"),
|
||||
];
|
||||
fill_gaps_from_mpls(&mut framework, &mpls);
|
||||
assert_eq!(
|
||||
framework.len(),
|
||||
8,
|
||||
"3 framework + 4 audio fills (2,3,5,6) + 1 subtitle fill (2) = 8"
|
||||
);
|
||||
// sort by (type, number) means audios first
|
||||
let audios: Vec<_> = framework
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.collect();
|
||||
assert_eq!(audios.len(), 6, "all 6 audio slots covered");
|
||||
// Slot 1 + 4 retain framework codec_hint
|
||||
assert_eq!(audios[0].codec_hint, "Atmos");
|
||||
assert_eq!(audios[3].codec_hint, "Commentary");
|
||||
// Slots 2, 3, 5, 6 are MPLS-derived
|
||||
assert_eq!(audios[1].codec_hint, "AC-3");
|
||||
assert_eq!(audios[2].codec_hint, "AC-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_append_skips_matching_type_lang_codec_tuples() {
|
||||
// If a "would-be orphan" actually shares (type, lang, codec)
|
||||
// with a label the framework or MPLS already produced, drop
|
||||
// it — the user-facing rendering would be a confusing
|
||||
// duplicate. Stream_number is computed from the EXISTING
|
||||
// labels' max(stream_number) per type so orphans (when they
|
||||
// do fire) sort cleanly at the tail.
|
||||
let labels = vec![
|
||||
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
|
||||
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
|
||||
];
|
||||
// Simulate the orphan dedup: build the existing-tuple set
|
||||
// the way the production function does, then check exclusion.
|
||||
use std::collections::HashSet;
|
||||
let existing: HashSet<(StreamLabelType, String, String)> = labels
|
||||
.iter()
|
||||
.map(|l| (l.stream_type, l.language.clone(), l.codec_hint.clone()))
|
||||
.collect();
|
||||
let candidate = (
|
||||
StreamLabelType::Audio,
|
||||
"eng".to_string(),
|
||||
"TrueHD".to_string(),
|
||||
);
|
||||
assert!(
|
||||
existing.contains(&candidate),
|
||||
"matching tuple must be detected as duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_append_genuine_orphan_assigned_next_stream_number() {
|
||||
// Hypothetical scenario: framework emitted audio 1+2, MPLS
|
||||
// gap-filled 3-5, CLPI has an orphan audio in (lang=jpn,
|
||||
// codec=DTS) that doesn't collide. Expected: append as audio
|
||||
// stream_number=6 (max existing + 1).
|
||||
let mut labels = vec![
|
||||
label(StreamLabelType::Audio, 1, "eng", "TrueHD 5.1"),
|
||||
label(StreamLabelType::Audio, 2, "fra", "AC-3 5.1"),
|
||||
label(StreamLabelType::Audio, 5, "eng", "AC-3 2.0"),
|
||||
];
|
||||
let max_audio: u16 = labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.map(|l| l.stream_number)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
assert_eq!(max_audio, 5);
|
||||
labels.push(label(StreamLabelType::Audio, max_audio + 1, "jpn", "DTS"));
|
||||
labels.sort_by_key(|l| (type_tag(l.stream_type), l.stream_number));
|
||||
let last_audio = labels
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.unwrap();
|
||||
assert_eq!(last_audio.stream_number, 6);
|
||||
assert_eq!(last_audio.language, "jpn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_groups_audio_before_subtitle() {
|
||||
let mut framework: Vec<StreamLabel> = Vec::new();
|
||||
let mpls = vec![
|
||||
label(StreamLabelType::Subtitle, 1, "eng", "PG"),
|
||||
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
|
||||
label(StreamLabelType::Subtitle, 2, "fra", "PG"),
|
||||
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
|
||||
];
|
||||
fill_gaps_from_mpls(&mut framework, &mpls);
|
||||
assert_eq!(framework.len(), 4);
|
||||
assert_eq!(framework[0].stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(framework[0].stream_number, 1);
|
||||
assert_eq!(framework[1].stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(framework[1].stream_number, 2);
|
||||
assert_eq!(framework[2].stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(framework[3].stream_type, StreamLabelType::Subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
// ── apply() integration tests ──────────────────────────────────────────────
|
||||
//
|
||||
// End-to-end coverage for the apply_labels + fill_defaults pipeline
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
//! Universal MPLS-based stream labels.
|
||||
//!
|
||||
//! Unlike the framework-specific parsers in this directory (dbp,
|
||||
//! pixelogic, ctrm, criterion, ...), this module is the *floor*:
|
||||
//! every Blu-ray ships with MPLS playlists under `/BDMV/PLAYLIST/`,
|
||||
//! and every MPLS file has an STN table with per-stream ISO 639-2
|
||||
//! language codes plus coding-type / channel-layout / sample-rate
|
||||
//! bytes from the BD spec.
|
||||
//!
|
||||
//! The framework parsers extract richer editorial labels ("English
|
||||
//! Dolby Atmos", "Director's Commentary") when the disc was authored
|
||||
//! with a recognized tool. When none of them match (e.g. a "no BD-J"
|
||||
//! disc, or an authoring framework we haven't catalogued), MPLS still
|
||||
//! gives us language + codec on every stream — enough to render
|
||||
//! something more useful than the bare PID.
|
||||
//!
|
||||
//! Output confidence is Low: MPLS carries language + codec but
|
||||
//! never purpose/qualifier info (no way to tell "Commentary" from
|
||||
//! "Normal" from the STN table alone). Higher-confidence framework
|
||||
//! parsers, when present, always win on the registry's max-by-confidence
|
||||
//! tiebreaker — MPLS is only chosen when nothing else matched.
|
||||
|
||||
use super::{
|
||||
LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType,
|
||||
vocab::{self, LangInfo},
|
||||
};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
|
||||
/// `.mpls` file. Cheap directory walk only — no sector reads.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
|
||||
return false;
|
||||
};
|
||||
dir.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && has_mpls_extension(&e.name))
|
||||
}
|
||||
|
||||
/// Walk every `*.mpls` in `/BDMV/PLAYLIST/`, parse it, and convert
|
||||
/// each StreamEntry to a [`StreamLabel`]. Streams shared across
|
||||
/// playlists (same PID) are deduped.
|
||||
///
|
||||
/// Returns `None` if no labels could be produced (e.g. no .mpls files
|
||||
/// parsed successfully, or every parsed stream was a type we skip
|
||||
/// like IG / DV EL).
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let playlist_dir = udf.find_dir("/BDMV/PLAYLIST")?;
|
||||
|
||||
// Collect mpls filenames first so we don't hold a borrow on udf
|
||||
// while we call udf.read_file (which takes &self).
|
||||
let mpls_names: Vec<String> = playlist_dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && has_mpls_extension(&e.name))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
|
||||
if mpls_names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
// (stream_type_tag, language, codec_hint, pid) — PID is the
|
||||
// canonical "same physical stream" key; type+lang+codec round
|
||||
// out the rare case where two distinct logical streams happen
|
||||
// to share a PID across playlists with different metadata.
|
||||
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
|
||||
|
||||
// Global 1-based counters keyed by StreamLabelType. Incremented
|
||||
// only when an entry survives dedup, so stream_numbers are dense
|
||||
// (1, 2, 3, ...) per type across the whole disc — not reset per
|
||||
// playlist. A disc with 2 MPLS files that each list the same
|
||||
// 8 audio streams ends up with audio_1..audio_8, not audio_1..
|
||||
// audio_16 or audio_1..audio_8 with audio_1 duplicated.
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for name in &mpls_names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(playlist) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio, // primary + secondary audio
|
||||
3 => StreamLabelType::Subtitle, // PG subtitle
|
||||
// 1 = primary video, 6 = secondary video, 7 = DV EL
|
||||
// → no StreamLabelType variant for video, skip.
|
||||
// 4 = IG (interactive graphics) — not a user-facing
|
||||
// stream, skip.
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
|
||||
let type_tag = type_tag(label_type);
|
||||
let key = (type_tag, language.clone(), codec_hint.clone(), entry.pid);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
|
||||
let stream_number = match label_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_idx += 1;
|
||||
audio_idx
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_idx += 1;
|
||||
sub_idx
|
||||
}
|
||||
};
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: label_type,
|
||||
language,
|
||||
name,
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint,
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// MPLS gives language + codec but never editorial info (no
|
||||
// commentary/SDH/director's cut). Low confidence means framework
|
||||
// parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always
|
||||
// win when they match. MPLS only gets chosen as the parser when
|
||||
// nothing else fired — exactly the universal-fallback role we want.
|
||||
Some(ParseResult::low(labels))
|
||||
}
|
||||
|
||||
fn has_mpls_extension(name: &str) -> bool {
|
||||
// Case-insensitive ".mpls" suffix. Some discs use uppercase,
|
||||
// some lowercase; UDF filenames preserve case but we don't.
|
||||
let n = name.len();
|
||||
if n < 5 {
|
||||
return false;
|
||||
}
|
||||
name[n - 5..].eq_ignore_ascii_case(".mpls")
|
||||
}
|
||||
|
||||
/// Lowercase + trim the raw 3-char ISO 639-2 code. If the lowered
|
||||
/// string maps via [`vocab::lang`] (it won't for plain "eng" — that
|
||||
/// matcher is for English-name fragments, not codes) use its
|
||||
/// canonical code; otherwise return the trimmed lowercase string.
|
||||
fn normalize_language(raw: &str) -> String {
|
||||
let trimmed = raw.trim().to_ascii_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// vocab::lang() matches free-form English names, not ISO 639-2
|
||||
// codes — so for the typical MPLS payload ("eng", "fra", ...)
|
||||
// it returns None and we keep the trimmed code.
|
||||
if let Some(LangInfo { code, .. }) = vocab::lang(&trimmed) {
|
||||
return code.to_string();
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
/// Human-readable English name for an ISO 639-2 code, or empty if
|
||||
/// the code is unknown. Kept inline rather than in vocab because
|
||||
/// vocab is the *reverse* mapping (name → code).
|
||||
pub(crate) fn language_display_name(iso: &str) -> String {
|
||||
match iso {
|
||||
"eng" => "English",
|
||||
"fra" | "fre" => "French",
|
||||
"spa" => "Spanish",
|
||||
"deu" | "ger" => "German",
|
||||
"ita" => "Italian",
|
||||
"jpn" => "Japanese",
|
||||
"zho" | "chi" => "Chinese",
|
||||
"kor" => "Korean",
|
||||
"por" => "Portuguese",
|
||||
"pol" => "Polish",
|
||||
"ces" | "cze" => "Czech",
|
||||
"hun" => "Hungarian",
|
||||
"nld" | "dut" => "Dutch",
|
||||
"ara" => "Arabic",
|
||||
"hin" => "Hindi",
|
||||
"tur" => "Turkish",
|
||||
"tha" => "Thai",
|
||||
"swe" => "Swedish",
|
||||
"nor" => "Norwegian",
|
||||
"dan" => "Danish",
|
||||
"fin" => "Finnish",
|
||||
"heb" => "Hebrew",
|
||||
"rus" => "Russian",
|
||||
"ell" | "gre" => "Greek",
|
||||
"vie" => "Vietnamese",
|
||||
"ind" => "Indonesian",
|
||||
"msa" | "may" => "Malay",
|
||||
"ukr" => "Ukrainian",
|
||||
"ron" | "rum" => "Romanian",
|
||||
"bul" => "Bulgarian",
|
||||
"hrv" => "Croatian",
|
||||
"srp" => "Serbian",
|
||||
"slk" | "slo" => "Slovak",
|
||||
"slv" => "Slovenian",
|
||||
"est" => "Estonian",
|
||||
"lav" => "Latvian",
|
||||
"lit" => "Lithuanian",
|
||||
"isl" | "ice" => "Icelandic",
|
||||
"eus" | "baq" => "Basque",
|
||||
"cat" => "Catalan",
|
||||
"glg" => "Galician",
|
||||
_ => "",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Map BD coding_type byte → codec name. Returns empty for unknown
|
||||
/// bytes (the table covers everything the spec defines, but unknown
|
||||
/// values are still possible on malformed discs).
|
||||
pub(crate) fn codec_name(coding_type: u8) -> &'static str {
|
||||
match coding_type {
|
||||
0x02 => "MPEG-2",
|
||||
0x1B => "H.264",
|
||||
0x24 => "HEVC",
|
||||
0x80 => "LPCM",
|
||||
0x81 => "AC-3",
|
||||
0x82 => "DTS",
|
||||
0x83 => "TrueHD",
|
||||
0x84 => "AC-3+",
|
||||
0x85 => "DTS-HD",
|
||||
0x86 => "DTS-HD MA",
|
||||
0x90 => "PG",
|
||||
0x91 => "IG",
|
||||
0xA1 => "AC-3+ Secondary",
|
||||
0xA2 => "DTS-HD Secondary",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the final `codec_hint`. For audio streams, optionally
|
||||
/// append " <channels>" and/or " <rate>" suffixes. Sample rate is
|
||||
/// only spelled out for non-48k (the universal default).
|
||||
fn build_codec_hint(label_type: StreamLabelType, entry: &crate::mpls::StreamEntry) -> String {
|
||||
let base = codec_name(entry.coding_type);
|
||||
if base.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if label_type != StreamLabelType::Audio {
|
||||
return base.to_string();
|
||||
}
|
||||
|
||||
let mut out = base.to_string();
|
||||
|
||||
let channels = match entry.audio_format {
|
||||
1 => Some("mono"),
|
||||
3 => Some("2.0"),
|
||||
6 => Some("5.1"),
|
||||
12 => Some("7.1"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ch) = channels {
|
||||
out.push(' ');
|
||||
out.push_str(ch);
|
||||
}
|
||||
|
||||
// 1 = 48 kHz (universal default, omit). Only call out higher rates.
|
||||
let rate = match entry.audio_rate {
|
||||
4 => Some("96kHz"),
|
||||
5 => Some("192kHz"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = rate {
|
||||
out.push(' ');
|
||||
out.push_str(r);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Dedup-tag for the label type. `u8` instead of `StreamLabelType`
|
||||
/// itself because the enum does not derive `Hash` / `Eq`-by-discriminant
|
||||
/// in a way that we want to couple to (and `==` works fine for the
|
||||
/// linear `Vec::contains` lookup we do).
|
||||
fn type_tag(t: StreamLabelType) -> u8 {
|
||||
match t {
|
||||
StreamLabelType::Audio => 1,
|
||||
StreamLabelType::Subtitle => 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mpls::{Playlist, StreamEntry};
|
||||
|
||||
fn audio_entry(pid: u16, coding: u8, fmt: u8, rate: u8, lang: &str) -> StreamEntry {
|
||||
StreamEntry {
|
||||
stream_type: 2,
|
||||
pid,
|
||||
coding_type: coding,
|
||||
video_format: 0,
|
||||
video_rate: 0,
|
||||
audio_format: fmt,
|
||||
audio_rate: rate,
|
||||
language: lang.to_string(),
|
||||
dynamic_range: 0,
|
||||
color_space: 0,
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn pg_entry(pid: u16, lang: &str) -> StreamEntry {
|
||||
StreamEntry {
|
||||
stream_type: 3,
|
||||
pid,
|
||||
coding_type: 0x90,
|
||||
video_format: 0,
|
||||
video_rate: 0,
|
||||
audio_format: 0,
|
||||
audio_rate: 0,
|
||||
language: lang.to_string(),
|
||||
dynamic_range: 0,
|
||||
color_space: 0,
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn playlist_with(streams: Vec<StreamEntry>) -> Playlist {
|
||||
Playlist {
|
||||
version: "0200".to_string(),
|
||||
play_items: Vec::new(),
|
||||
streams,
|
||||
marks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the same conversion logic that `parse()` runs on real
|
||||
/// disc data, but starting from already-parsed Playlists so we
|
||||
/// don't have to synthesize valid MPLS bytes.
|
||||
fn labels_from_playlists(playlists: &[Playlist]) -> Vec<StreamLabel> {
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
|
||||
|
||||
for playlist in playlists {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio,
|
||||
3 => StreamLabelType::Subtitle,
|
||||
_ => continue,
|
||||
};
|
||||
let stream_number = match label_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_idx += 1;
|
||||
audio_idx
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_idx += 1;
|
||||
sub_idx
|
||||
}
|
||||
};
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
let key = (
|
||||
type_tag(label_type),
|
||||
language.clone(),
|
||||
codec_hint.clone(),
|
||||
entry.pid,
|
||||
);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: label_type,
|
||||
language,
|
||||
name,
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint,
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpls_audio_streams_become_labels() {
|
||||
// Two audio streams: English TrueHD 7.1 48k, French AC-3 5.1 48k.
|
||||
let pl = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"),
|
||||
audio_entry(0x1101, 0x81, 6, 1, "fra"),
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 2);
|
||||
|
||||
// English TrueHD 7.1
|
||||
let a = &labels[0];
|
||||
assert_eq!(a.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(a.stream_number, 1);
|
||||
assert_eq!(a.language, "eng");
|
||||
assert_eq!(a.name, "English");
|
||||
assert_eq!(a.codec_hint, "TrueHD 7.1");
|
||||
assert_eq!(a.purpose, LabelPurpose::Normal);
|
||||
assert_eq!(a.qualifier, LabelQualifier::None);
|
||||
assert_eq!(a.variant, "");
|
||||
|
||||
// French AC-3 5.1
|
||||
let b = &labels[1];
|
||||
assert_eq!(b.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(b.stream_number, 2);
|
||||
assert_eq!(b.language, "fra");
|
||||
assert_eq!(b.name, "French");
|
||||
assert_eq!(b.codec_hint, "AC-3 5.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpls_pg_streams_become_subtitle_labels() {
|
||||
let pl = playlist_with(vec![
|
||||
pg_entry(0x1200, "eng"),
|
||||
pg_entry(0x1201, "spa"),
|
||||
pg_entry(0x1202, "fra"),
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 3);
|
||||
for label in &labels {
|
||||
assert_eq!(label.stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(label.codec_hint, "PG");
|
||||
}
|
||||
assert_eq!(labels[0].stream_number, 1);
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
assert_eq!(labels[0].name, "English");
|
||||
assert_eq!(labels[1].stream_number, 2);
|
||||
assert_eq!(labels[1].language, "spa");
|
||||
assert_eq!(labels[1].name, "Spanish");
|
||||
assert_eq!(labels[2].stream_number, 3);
|
||||
assert_eq!(labels[2].language, "fra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_streams_across_playlists() {
|
||||
// Two playlists, same English TrueHD 7.1 PID 0x1100 in both.
|
||||
// Expect one Audio label, not two.
|
||||
let pl1 = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"),
|
||||
audio_entry(0x1101, 0x81, 6, 1, "fra"),
|
||||
]);
|
||||
let pl2 = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"), // duplicate
|
||||
audio_entry(0x1102, 0x82, 6, 1, "deu"), // new
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl1, pl2]);
|
||||
// Expected: eng@0x1100, fra@0x1101, deu@0x1102 — three uniques.
|
||||
assert_eq!(labels.len(), 3);
|
||||
// PID isn't stored on StreamLabel, so assert on the surviving
|
||||
// language set instead.
|
||||
let mut langs: Vec<String> = labels.iter().map(|l| l.language.clone()).collect();
|
||||
langs.sort();
|
||||
assert_eq!(langs, vec!["deu", "eng", "fra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coding_type_to_codec_hint_table() {
|
||||
// Spot-check every entry in the spec table. Audio entries
|
||||
// come back bare (no channels/rate set) so codec_hint is the
|
||||
// codec name alone.
|
||||
let cases: &[(u8, &str)] = &[
|
||||
(0x02, "MPEG-2"),
|
||||
(0x1B, "H.264"),
|
||||
(0x24, "HEVC"),
|
||||
(0x80, "LPCM"),
|
||||
(0x81, "AC-3"),
|
||||
(0x82, "DTS"),
|
||||
(0x83, "TrueHD"),
|
||||
(0x84, "AC-3+"),
|
||||
(0x85, "DTS-HD"),
|
||||
(0x86, "DTS-HD MA"),
|
||||
(0x90, "PG"),
|
||||
(0x91, "IG"),
|
||||
(0xA1, "AC-3+ Secondary"),
|
||||
(0xA2, "DTS-HD Secondary"),
|
||||
];
|
||||
for (ct, expected) in cases {
|
||||
assert_eq!(
|
||||
codec_name(*ct),
|
||||
*expected,
|
||||
"coding_type 0x{:02X} should map to {}",
|
||||
ct,
|
||||
expected
|
||||
);
|
||||
}
|
||||
// Unknown bytes return empty.
|
||||
assert_eq!(codec_name(0x00), "");
|
||||
assert_eq!(codec_name(0xFF), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_format_appends_channel_layout() {
|
||||
let mono = audio_entry(1, 0x83, 1, 1, "eng");
|
||||
let stereo = audio_entry(2, 0x83, 3, 1, "eng");
|
||||
let surround_51 = audio_entry(3, 0x83, 6, 1, "eng");
|
||||
let surround_71 = audio_entry(4, 0x83, 12, 1, "eng");
|
||||
let unknown = audio_entry(5, 0x83, 0, 1, "eng");
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &mono),
|
||||
"TrueHD mono"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &stereo),
|
||||
"TrueHD 2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &surround_51),
|
||||
"TrueHD 5.1"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &surround_71),
|
||||
"TrueHD 7.1"
|
||||
);
|
||||
assert_eq!(build_codec_hint(StreamLabelType::Audio, &unknown), "TrueHD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_rate_only_shows_above_48k() {
|
||||
// 48 kHz (rate=1) is the universal default → not surfaced.
|
||||
let r48 = audio_entry(1, 0x83, 6, 1, "eng");
|
||||
// 96 kHz (rate=4) → surfaced.
|
||||
let r96 = audio_entry(2, 0x83, 6, 4, "eng");
|
||||
// 192 kHz (rate=5) → surfaced.
|
||||
let r192 = audio_entry(3, 0x83, 6, 5, "eng");
|
||||
assert_eq!(build_codec_hint(StreamLabelType::Audio, &r48), "TrueHD 5.1");
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &r96),
|
||||
"TrueHD 5.1 96kHz"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &r192),
|
||||
"TrueHD 5.1 192kHz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_iso_code_passes_through_without_display_name() {
|
||||
// Made-up code: keep the raw lowercase code as `language`,
|
||||
// but `name` is empty because we don't know it.
|
||||
let pl = playlist_with(vec![audio_entry(0x1100, 0x83, 6, 1, "xyz")]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].language, "xyz");
|
||||
assert_eq!(labels[0].name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ig_and_dv_streams_are_skipped() {
|
||||
// stream_type 4 = IG, 7 = DV EL — both must not surface.
|
||||
let mut ig = pg_entry(0x1400, "eng");
|
||||
ig.stream_type = 4;
|
||||
let mut dv = audio_entry(0x1011, 0x24, 0, 0, "");
|
||||
dv.stream_type = 7;
|
||||
let pl = playlist_with(vec![ig, dv]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert!(labels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_audio_becomes_audio_label() {
|
||||
// stream_type 5 = secondary audio. The conversion should
|
||||
// still produce an Audio label (the registry's apply path
|
||||
// can ignore secondary if it wants — this module just
|
||||
// surfaces what's there).
|
||||
let mut sec = audio_entry(0x1A00, 0x83, 3, 1, "eng");
|
||||
sec.stream_type = 5;
|
||||
sec.secondary = true;
|
||||
let pl = playlist_with(vec![sec]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(labels[0].codec_hint, "TrueHD 2.0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! PNG-filename language token parser — stubbed (noop) pending need.
|
||||
//!
|
||||
//! ## What this would do
|
||||
//!
|
||||
//! Some discs encode per-language menu localization as pre-rendered PNG
|
||||
//! menu buttons, one per language, with the language token embedded in
|
||||
//! the filename. Examples observed in the 2026-05-10 corpus:
|
||||
//!
|
||||
//! - **disc-01 (The Amateur)** — `<region>_<lang>_<context>_<format>.png`
|
||||
//! Region prefix: `USA` / `UK` / `JPN` / etc.
|
||||
//! Lang tokens (3-char, uppercase): `ENG`, `FRC`, `FRP`, `DEU`, `DUT`,
|
||||
//! `ITA`, `JPN`, `LAS`, `CSP`, `POL`, `CZE` (11 languages)
|
||||
//!
|
||||
//! - **disc-09 (Dune orig)** — `<title>_<variant>_<lang>_Composite<N>.png`
|
||||
//! Lang tokens (3-char, mixed-case): `Eng`, `Ger` (2 languages)
|
||||
//!
|
||||
//! ## Why stubbed
|
||||
//!
|
||||
//! MPLS already gives per-stream `language` + `coding_type` + stream-type
|
||||
//! (audio vs subtitle) on every disc. For the 2 unknown-framework discs
|
||||
//! that PNG filenames would close (disc-01, disc-09), MPLS will produce
|
||||
//! a strict superset of what filenames could give us, because MPLS knows
|
||||
//! per-stream attribution while filenames only know "the disc offers
|
||||
//! these N language buttons."
|
||||
//!
|
||||
//! The **only** thing PNG filenames give us that MPLS doesn't is **studio
|
||||
//! variant disambiguation**:
|
||||
//! - `FRC` (French Canadian) vs `FRP` (French Parisian) — MPLS just says `fra`
|
||||
//! - `LAS` (Latin American Spanish) vs `CSP` (Castilian Spanish) — MPLS just says `spa`
|
||||
//!
|
||||
//! That's niche enough that it doesn't justify implementing right now.
|
||||
//! Reactivate this parser only when:
|
||||
//! 1. We hit a disc where MPLS is malformed/empty AND PNG filenames are
|
||||
//! the only language hint, OR
|
||||
//! 2. A downstream consumer needs the studio variant suffix for output
|
||||
//! naming (e.g. `Title (French Canadian).mkv` vs `Title (French).mkv`).
|
||||
//!
|
||||
//! ## When reactivating
|
||||
//!
|
||||
//! Implement `parse` to:
|
||||
//! 1. Iterate top-level PNG paths in `/BDMV/JAR/` (and `<id>/` subdirs).
|
||||
//! 2. Tokenize each filename on `_` / `-` / `.`
|
||||
//! 3. Match each token against an alias table:
|
||||
//! - ISO 639-1 / 639-2 standard codes
|
||||
//! - Studio variants: `FRC`/`FRP` → `fra-CA`/`fra-FR`,
|
||||
//! `LAS`/`CSP` → `spa-419`/`spa-ES`,
|
||||
//! mixed-case shortforms `Eng`/`Ger`/`Fra`/`Spa`/`Jpn` → ISO 639-2
|
||||
//! - Country prefix filter: drop `USA`/`UK`/`JPN`/`AUS`/`GER`/`FR` when
|
||||
//! they appear in position 0 (those are region markers, not langs).
|
||||
//! 4. Deduplicate. Confidence stays `Low` because we still don't know
|
||||
//! per-stream codec or audio/subtitle attribution.
|
||||
//!
|
||||
//! Wire as ENRICHMENT after MPLS in `mod.rs::analyze`, not as a primary
|
||||
//! parser: PNG filenames upgrade `lang=fra` to `lang=fra-CA` when both
|
||||
//! sources agree on the disc; they should never overwrite MPLS data.
|
||||
|
||||
use super::ParseResult;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
/// Stub: returns false so the dispatcher never calls `parse`. Reactivate
|
||||
/// by checking for the patterns described in the module docs.
|
||||
#[allow(dead_code)] // module-level noop, not wired into PARSERS until needed
|
||||
pub fn detect(_udf: &UdfFs) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Stub: returns None. See module docs for the implementation sketch.
|
||||
#[allow(dead_code)] // module-level noop, not wired into PARSERS until needed
|
||||
pub fn parse(_reader: &mut dyn SectorReader, _udf: &UdfFs) -> Option<ParseResult> {
|
||||
None
|
||||
}
|
||||
+10
-7
@@ -82,7 +82,7 @@ pub mod event;
|
||||
pub mod halt;
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod ifo;
|
||||
pub(crate) mod io;
|
||||
pub mod io;
|
||||
pub mod keydb;
|
||||
pub mod labels;
|
||||
pub(crate) mod mpls;
|
||||
@@ -128,16 +128,19 @@ pub use halt::Halt;
|
||||
|
||||
// Generic bounded producer/consumer primitive used by sweep, patch, and
|
||||
// mux to overlap reads with writes via a dedicated consumer thread.
|
||||
// `Pipeline::spawn(depth, sink)` spawns the consumer; `pipe.send(item)`
|
||||
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
|
||||
// pushes one item with back-pressure; `pipe.finish()` joins the
|
||||
// consumer and surfaces its `close()` output. Callers implement `Sink`
|
||||
// to define per-item behaviour and end-of-stream finalisation.
|
||||
//
|
||||
// `DEFAULT_PIPELINE_DEPTH` (=4) is the depth sweep + mux use; patch
|
||||
// uses `WRITE_THROUGH_DEPTH` (=1) so each read fully drains before the
|
||||
// next can enqueue. Returning `Flow::Stop` from `apply` ends the
|
||||
// consumer cleanly (still calls `close()`).
|
||||
pub use io::pipeline::{DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_THROUGH_DEPTH};
|
||||
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
|
||||
// most should use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
|
||||
// `apply` ends the consumer cleanly (still calls `close()`).
|
||||
pub use io::pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
|
||||
pub use event::{Event, EventKind};
|
||||
|
||||
@@ -173,6 +173,13 @@ impl DiscStream {
|
||||
let extents = title.extents.clone();
|
||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||
|
||||
// Debug log reader type at construction — critical for diagnosing mux reading from drive instead of ISO
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"DiscStream constructed with reader type: {}",
|
||||
std::any::type_name::<dyn SectorReader>()
|
||||
);
|
||||
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
@@ -315,10 +322,19 @@ impl DiscStream {
|
||||
// this loop can spend minutes shrinking and skipping sectors; without
|
||||
// the check, Stop wouldn't take effect until the outer PES read() loop
|
||||
// finally emits a frame, which may never happen.
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if self.is_halted() {
|
||||
return Err(crate::error::Error::Halted.into());
|
||||
}
|
||||
|
||||
// Debug: log slow reads during mux — helps diagnose stalls
|
||||
if cfg!(debug_assertions) && start_time.elapsed().as_secs() > 5 {
|
||||
tracing::debug!(target: "mux", "fill_extents waiting at LBA {} ({}s elapsed, sectors={})", lba, start_time.elapsed().as_secs(), remaining);
|
||||
}
|
||||
|
||||
let mut sectors = remaining.min(self.adaptive.current() as u32) as u16;
|
||||
// Align to 3-sector AACS units when possible. Partial units at
|
||||
// extent boundaries are safely handled by decrypt_sectors().
|
||||
|
||||
+5
-1
@@ -204,7 +204,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
let title = disc.titles[idx].clone();
|
||||
let keys = disc.decrypt_keys();
|
||||
let format = disc.content_format;
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 64, format);
|
||||
// ISO file: use large batch size (16 MB) — sequential read from fast storage, no bad sectors.
|
||||
// Physical drives need small batches for adaptive error handling and retry logic.
|
||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||
let mut stream =
|
||||
DiscStream::new(Box::new(reader), title, keys, ISO_MUX_BATCH_SECTORS, format);
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
@@ -655,12 +656,16 @@ fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
|
||||
|
||||
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
|
||||
//
|
||||
// The video-damage-time display needs bytes_unreadable_total (confirmed dead)
|
||||
// separate from bytes_pending_total (might still recover). This test verifies
|
||||
// that a Pass 2 with some confirmed failures produces correct field values.
|
||||
// 2026-05-11 design call: Pass N never marks bytes as `Unreadable` mid-multipass —
|
||||
// failed reads stay `NonTrimmed` so the next pass can retry them. The orchestrator
|
||||
// (autorip) promotes still-NonTrimmed bytes to Unreadable after the FINAL retry
|
||||
// pass completes. This test was rewritten from its pre-design-call shape (which
|
||||
// asserted Pass 2 produced bytes_unreadable > 0) to verify the new invariant:
|
||||
// pass-level retries keep failed bytes in `bytes_pending` so subsequent passes
|
||||
// get more shots at them.
|
||||
|
||||
#[test]
|
||||
fn test_pass_progress_separates_unreadable_from_pending() {
|
||||
fn test_pass2_leaves_failed_reads_as_pending_not_unreadable() {
|
||||
let capacity_sectors: u32 = 128;
|
||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
|
||||
@@ -734,24 +739,30 @@ fn test_pass_progress_separates_unreadable_from_pending() {
|
||||
pass2.bytes_good, 0,
|
||||
"pass2: still no good sectors (reader always fails)"
|
||||
);
|
||||
assert!(
|
||||
pass2.bytes_unreadable > 0,
|
||||
"pass2: some sectors confirmed unreadable"
|
||||
// 2026-05-11 design: pass-level retries do NOT promote failed bytes
|
||||
// to Unreadable. Failed bytes stay NonTrimmed (pending) so a later
|
||||
// pass can retry. End-of-recovery promotion is an orchestrator
|
||||
// concern (autorip), not the patch loop's.
|
||||
assert_eq!(
|
||||
pass2.bytes_unreadable, 0,
|
||||
"pass2: Disc::patch never marks Unreadable mid-multipass — orchestrator promotes after final pass"
|
||||
);
|
||||
assert!(
|
||||
pass2.bytes_pending < pass1.bytes_pending,
|
||||
"pass2: fewer pending sectors than pass1"
|
||||
// bytes_pending stays at total_bytes because everything still
|
||||
// failed and nothing got recovered or promoted out of pending.
|
||||
assert_eq!(
|
||||
pass2.bytes_pending, total_bytes,
|
||||
"pass2: failed bytes remain NonTrimmed for the next pass to retry"
|
||||
);
|
||||
|
||||
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
|
||||
let observed_pending = last_pending.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
observed_unreadable > 0,
|
||||
"progress should report confirmed unreadable bytes"
|
||||
assert_eq!(
|
||||
observed_unreadable, 0,
|
||||
"progress should report zero confirmed-unreadable mid-pass under the new design"
|
||||
);
|
||||
assert!(
|
||||
observed_pending == 0 || observed_pending < total_bytes,
|
||||
"pending should shrink as sectors are confirmed unreadable"
|
||||
observed_pending > 0,
|
||||
"progress should report pending bytes as the reader keeps failing"
|
||||
);
|
||||
|
||||
// Video damage time: unreadable / total * duration
|
||||
|
||||
@@ -70,8 +70,14 @@ impl SectorReader for PatternedSectorReader {
|
||||
});
|
||||
}
|
||||
}
|
||||
for chunk in buf.chunks_mut(SECTOR_SIZE) {
|
||||
chunk.fill((lba & 0xff) as u8);
|
||||
// Fill each sector with ITS OWN LBA byte, not the starting LBA's
|
||||
// byte. This matches real drive behavior: a multi-sector READ
|
||||
// returns per-sector-correct data. Pre-0.18.13 only single-sector
|
||||
// reads were exercised by patch tests, so the cheaper "fill the
|
||||
// whole batch with one byte" worked; adaptive batching needs the
|
||||
// per-sector pattern to verify correct positioning.
|
||||
for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() {
|
||||
chunk.fill(((lba + i as u32) & 0xff) as u8);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
@@ -94,6 +100,7 @@ fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user