diff --git a/src/bdnav/index.rs b/src/bdnav/index.rs index 6dca560e..27b506dd 100644 --- a/src/bdnav/index.rs +++ b/src/bdnav/index.rs @@ -1,5 +1,5 @@ //! Parse `/BDMV/index.bdmv` — the BD navigation index (First Play, Top Menu, -//! and the title table), mirroring libbluray `index_parse.c`. This is read as a +//! and the title table), per its documented binary layout. This is read as a //! documented binary format, never executed: every field is bounds-checked and //! any malformed input yields `None` (the nav resolver then abstains). //! diff --git a/src/bdnav/mobj.rs b/src/bdnav/mobj.rs index bbd5d1a3..2679ae6c 100644 --- a/src/bdnav/mobj.rs +++ b/src/bdnav/mobj.rs @@ -1,5 +1,5 @@ -//! Parse `/BDMV/MovieObject.bdmv` — the HDMV navigation programs, mirroring -//! libbluray `mobj_parse.c`. Read as a documented format, never executed here: +//! Parse `/BDMV/MovieObject.bdmv` — the HDMV navigation programs, per their +//! documented binary layout. Read as a documented format, never executed here: //! bounds-checked, never panics. //! //! Layout: `"MOBJ"` + version(4) + reserved… ; `MovieObjects()` at byte 40 is diff --git a/src/bdnav/vm.rs b/src/bdnav/vm.rs index 60af7452..33ad2f1a 100644 --- a/src/bdnav/vm.rs +++ b/src/bdnav/vm.rs @@ -137,10 +137,8 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti return None; } let obj = vm.mobjs.get(obj_id)?; - let c = match obj.cmds.get(pc) { - Some(c) => *c, - None => return None, // ran off the end without a feature PlayPL - }; + // Ran off the end without a feature PlayPL → abstain. + let c = *obj.cmds.get(pc)?; let mut npc = pc + 1; let dst = if c.op_cnt > 0 { vm.fetch(c.imm_op1, c.dst) @@ -223,35 +221,33 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti } // SET (sub_grp 0). SETSYSTEM (sub_grp 1) only mutates system PSRs // whose values the feature path does not branch on — skip it. - 2 => { - if c.sub_grp == 0 { - let r: Option = match c.set_opt { - 0x01 => Some(src), // MOVE - 0x02 => { - vm.wr(c.dst, src); // SWAP - vm.wr(c.src, dst); - None - } - 0x03 => Some(dst.wrapping_add(src)), - 0x04 => Some(dst.saturating_sub(src)), - 0x05 => Some(dst.wrapping_mul(src)), - 0x06 => Some(if src > 0 { dst / src } else { 0xffff_ffff }), - 0x07 => Some(if src > 0 { dst % src } else { 0xffff_ffff }), - 0x08 => Some(dst), // RND — deterministic stand-in - 0x09 => Some(dst & src), - 0x0a => Some(dst | src), - 0x0b => Some(dst ^ src), - 0x0c => Some(dst | (1u32 << (src & 31))), - 0x0d => Some(dst & !(1u32 << (src & 31))), - 0x0e => Some(dst.wrapping_shl(src & 31)), - 0x0f => Some(dst.wrapping_shr(src & 31)), - _ => None, - }; - if let Some(r) = r - && !c.imm_op1 - { - vm.wr(c.dst, r); + 2 if c.sub_grp == 0 => { + let r: Option = match c.set_opt { + 0x01 => Some(src), // MOVE + 0x02 => { + vm.wr(c.dst, src); // SWAP + vm.wr(c.src, dst); + None } + 0x03 => Some(dst.wrapping_add(src)), + 0x04 => Some(dst.saturating_sub(src)), + 0x05 => Some(dst.wrapping_mul(src)), + 0x06 => Some(dst.checked_div(src).unwrap_or(0xffff_ffff)), + 0x07 => Some(dst.checked_rem(src).unwrap_or(0xffff_ffff)), + 0x08 => Some(dst), // RND — deterministic stand-in + 0x09 => Some(dst & src), + 0x0a => Some(dst | src), + 0x0b => Some(dst ^ src), + 0x0c => Some(dst | (1u32 << (src & 31))), + 0x0d => Some(dst & !(1u32 << (src & 31))), + 0x0e => Some(dst.wrapping_shl(src & 31)), + 0x0f => Some(dst.wrapping_shr(src & 31)), + _ => None, + }; + if let Some(r) = r + && !c.imm_op1 + { + vm.wr(c.dst, r); } } _ => {} diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 5047b7c5..ecadf669 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -2453,6 +2453,23 @@ impl Disc { .filter(|t| t.has_probable_video()) .map(|t| t.duration_secs) .fold(0.0_f64, f64::max); + // Largest video-bearing title's byte size, used as a plausibility floor + // below. A nav/authoring signal can legitimately resolve a seamless-branch + // or Dolby-Vision presentation playlist that a player navigates to but + // that carries almost no unique payload (e.g. a 0.4 GB / 102-clip branch + // sitting beside the 91.5 GB single-clip feature). Those are correct for + // playback but wrong as a RIP target, so a promoted title must also hold a + // meaningful fraction of this size; otherwise it falls through to the + // composite/size ranking, which recovers the substantial feature. + let max_video_size = titles + .iter() + .filter(|t| t.has_probable_video()) + .map(|t| t.size_bytes) + .max() + .unwrap_or(0); + let carries_feature_payload = |t: &DiscTitle| { + max_video_size == 0 || t.size_bytes as f64 >= 0.10 * max_video_size as f64 + }; titles .iter() .enumerate() @@ -2480,11 +2497,16 @@ impl Disc { let authoring = !composite && hint.is_some_and(|h| h.matches(p.playlist_id, &p.playlist)) && p.has_video() - && (longest_video_dur <= 0.0 || p.duration_secs >= 0.5 * longest_video_dur); + && (longest_video_dur <= 0.0 || p.duration_secs >= 0.5 * longest_video_dur) + && carries_feature_payload(p); // The nav result is video-gated exactly like the authoring hint, // so a mis-resolved playlist that happens to be streamless can - // never win (and thus cannot reopen the decoy hole). - let nav = nav_feature == Some(p.playlist_id) && p.has_video(); + // never win (and thus cannot reopen the decoy hole). It is also + // payload-gated: a player may navigate to a low-byte branch/DV + // presentation, but that is never the rip target. + let nav = nav_feature == Some(p.playlist_id) + && p.has_video() + && carries_feature_payload(p); TitleRank { nav, authoring, @@ -5167,6 +5189,31 @@ mod tests { ); } + /// Regression (Spotlight 2015 UHD): the disc navigates to a Dolby-Vision + /// seamless-branch playlist (`00001` = 102 tiny clips, 0.4 GB) that has real + /// video streams but carries almost no payload, sitting beside the 91.5 GB + /// single-clip feature (`00002`). A player plays the branch; a RIP wants the + /// feature. The nav pick is video-gated but must ALSO clear the payload floor, + /// so it falls through to size ranking and the substantial feature wins. + #[test] + fn nav_feature_ignored_when_target_is_a_low_payload_branch() { + let branch = bd_title("00001.mpls", 1, 7500.0, 400_000_000, &["b01", "b02", "b03"]); + let feature = bd_title("00002.mpls", 2, 7680.0, 91_500_000_000, &["00002"]); + let capacity = 100_000_000_000u64; + + assert!( + branch.has_probable_video(), + "the DV branch has video streams (only its payload is tiny)" + ); + let mut titles = vec![branch, feature]; + // Nav resolves the branch (1) like a real player — must be payload-gated. + Disc::sort_titles_by_main_feature(&mut titles, capacity, None, Some(1)); + assert_eq!( + titles[0].playlist_id, 2, + "the 91.5 GB feature wins; the 0.4 GB nav-picked branch is not a rip target" + ); + } + /// A hint pointing at a STREAMLESS entry must not resurrect the decoy bug: /// the authoring preference requires the designated title to have real video /// streams, and a clip-less streamless title fails the `has-video` floor too.