//! Structured scan diagnostics — the `--log-level 3` self-diagnosing dump. //! //! A bug report log must be self-diagnosing: everything needed to explain //! *why* freemkv made the choices it did at scan must be in the log, in a //! compact, machine-parseable form. This module emits one terse line per row //! (title, cell, stream, decision) under the `tracing` target //! `freemkv::diag`, which the CLI routes to `log.txt` when `--log-level 3` //! (debug) is set. //! //! Format conventions (stable, greppable): //! - Every line is prefixed by a `tag=` so a log scraper can filter //! (`disc`, `title`, `dvd.cell`, `dvd.vattr`, `dvd.aattr`, `bd.clip`, //! `bd.mark`, `aacs`, `stream`, `decision`). //! - Raw bytes are shown as `0xNN` next to their decode so a wrong decode //! is obvious against the raw value. //! - This module only READS already-parsed scan state — it never re-reads //! the disc and never mutates anything. //! //! The DVD per-cell table (with the raw cell-category byte) is emitted from //! the IFO scan itself ([`dump_dvd_cells`]), because the per-cell //! `ifo::DvdCell` detail is lowered away before the `Disc` is built. The //! `Disc`-level dump ([`dump_disc`]) covers everything that survives //! lowering: titles, streams, the picked main feature, and AACS state. use crate::disc::{ AudioChannels, ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, SampleRate, Stream, }; use crate::ifo::{CellCategory, DvdTitle}; const DIAG: &str = "freemkv::diag"; // ── small format helpers (pure, unit-testable) ────────────────────────────── /// Compact name for a [`Resolution`] with the interlace marker preserved. pub fn res_str(r: Resolution) -> &'static str { match r { Resolution::R480i => "480i", Resolution::R480p => "480p", Resolution::R576i => "576i", Resolution::R576p => "576p", Resolution::R720p => "720p", Resolution::R1080i => "1080i", Resolution::R1080p => "1080p", Resolution::R2160p => "2160p", Resolution::R4320p => "4320p", Resolution::Unknown => "res?", } } /// Frames-per-second string for a [`FrameRate`]. pub fn fps_str(f: FrameRate) -> &'static str { match f { FrameRate::F23_976 => "23.976", FrameRate::F24 => "24", FrameRate::F25 => "25", FrameRate::F29_97 => "29.97", FrameRate::F30 => "30", FrameRate::F50 => "50", FrameRate::F59_94 => "59.94", FrameRate::F60 => "60", FrameRate::Unknown => "fps?", } } /// PAL/NTSC field-rate family inferred from the frame rate (DVD has no /// explicit field, so this is the colour/standard the muxer stamps). pub fn tv_system_str(f: FrameRate) -> &'static str { match f { FrameRate::F25 | FrameRate::F50 => "PAL", FrameRate::F23_976 | FrameRate::F29_97 | FrameRate::F59_94 => "NTSC", _ => "—", } } /// CICP-ish short name for a [`ColorSpace`]. pub fn color_str(c: ColorSpace) -> &'static str { match c { ColorSpace::Bt709 => "BT.709", ColorSpace::Bt2020 => "BT.2020", ColorSpace::Bt470bg => "BT.470BG", ColorSpace::Smpte170m => "SMPTE-170M", ColorSpace::Unknown => "color?", } } /// HDR format short name. pub fn hdr_str(h: HdrFormat) -> &'static str { match h { HdrFormat::Sdr => "SDR", HdrFormat::Hdr10 => "HDR10", HdrFormat::Hdr10Plus => "HDR10+", HdrFormat::DolbyVision => "DoVi", HdrFormat::Hlg => "HLG", } } /// Channel count from an [`AudioChannels`] layout (what lands in the MKV /// `Channels` element). pub fn channel_count(ch: AudioChannels) -> u8 { match ch { AudioChannels::Mono => 1, AudioChannels::Stereo => 2, AudioChannels::Stereo21 => 3, AudioChannels::Quad => 4, AudioChannels::Surround50 => 5, AudioChannels::Surround51 => 6, AudioChannels::Surround61 => 7, AudioChannels::Surround71 => 8, AudioChannels::Unknown => 0, } } /// Sample-rate in Hz for a [`SampleRate`]. pub fn sample_rate_hz(s: SampleRate) -> u32 { match s { SampleRate::S44_1 => 44100, SampleRate::S48 => 48000, SampleRate::S88_2 => 88200, SampleRate::S96 => 96000, SampleRate::S176_4 => 176400, SampleRate::S192 => 192000, SampleRate::S48_96 => 96000, SampleRate::S48_192 => 192000, SampleRate::Unknown => 0, } } // ── DVD cell-category dump (from the IFO scan, pre-lowering) ───────────────── /// One formatted cell row for the DVD per-PGC cell table. Returned as a /// string so it can be unit-tested without a logger. /// /// Columns: `idx`, raw category (`cat=0xNN`) + decoded fields, first/last /// sector, duration, and the keep/drop verdict from the bug-4 leading-cell /// filter. pub fn dvd_cell_row(idx: usize, cell: &crate::ifo::DvdCell, dropped: bool) -> String { let c = CellCategory::decode(cell.category); // Per-cell keep/skip REASON (self-sufficient bug log): a dropped cell is a // leading secondary angle/interleave block piece; a kept cell is either the // first feature cell or genuine feature content. This makes the // leading-cell-filter decision auditable from the log without the disc. let verdict = if dropped { "DROP(leading-secondary-block-piece)" } else if c.is_secondary_block_piece() { // Kept despite being a secondary piece — only happens past the leading // run (the filter stops at the first plain feature cell). "keep(feature-body)" } else { "keep(plain-feature)" }; format!( "tag=dvd.cell idx={idx} cat=0x{:02X} block_mode={} block_type={} \ seamless={} ilv={} stc={} angle={} plain={} first={} last={} dur={:.1}s {}", cell.category, c.block_mode, c.block_type, c.seamless_play as u8, c.interleaved as u8, c.stc_discontinuity as u8, c.seamless_angle as u8, c.is_plain_feature() as u8, cell.first_sector, cell.last_sector, cell.duration_secs, verdict, ) } /// Emit the per-PGC cell table for one DVD title during the IFO scan. /// /// `vts`/`title` identify the row group; `title` is the `DvdTitle` whose /// cells (and bug-4 leading-cell verdict) are dumped. Called from /// `scan_dvd_titles` while the `DvdTitle` is still in scope (the per-cell /// category byte is lowered away before the `Disc` exists). pub fn dump_dvd_cells(vts: u8, title_num: u16, title: &DvdTitle) { if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) { return; } let feature_start = title.feature_start_cell(); tracing::debug!( target: DIAG, "tag=dvd.pgc vts={vts} title={title_num} cells={} chapters={} \ dur={:.1}s feature_start_cell={feature_start}", title.cells.len(), title.chapters, title.duration_secs, ); for (i, cell) in title.cells.iter().enumerate() { tracing::debug!(target: DIAG, "{}", dvd_cell_row(i, cell, i < feature_start)); } // Chapter/PTT map (program → cumulative start time). for (i, &t) in title.chapter_times.iter().enumerate() { tracing::debug!( target: DIAG, "tag=dvd.chap vts={vts} title={title_num} ch={} time={:.1}s", i + 1, t, ); } } /// Emit the IFO `video_attr` / `audio_attr` decode for one DVD title set, /// showing the raw bytes next to their decoded meaning. Called from the IFO /// scan with the still-parsed `ifo::DvdTitleSet` view. pub fn dump_dvd_attrs(ts: &crate::ifo::DvdTitleSet) { if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) { return; } tracing::debug!( target: DIAG, "tag=dvd.vobs vts={} vob_start_sector={}", ts.vts_number, ts.vob_start_sector, ); let v = &ts.video; tracing::debug!( target: DIAG, "tag=dvd.vattr vts={} codec={:?} res={} aspect={:?} std={:?}", ts.vts_number, v.codec, res_str(v.resolution), v.aspect, v.standard, ); for (i, a) in ts.audio_streams.iter().enumerate() { tracing::debug!( target: DIAG, "tag=dvd.aattr vts={} idx={i} codec={:?} ch={} sr={}Hz lang={:?} sub_id={:?}", ts.vts_number, a.codec, a.channels, a.sample_rate, a.language, a.sub_stream_id.map(|x| format!("0x{x:02X}")), ); } for (i, s) in ts.subtitle_streams.iter().enumerate() { tracing::debug!( target: DIAG, "tag=dvd.sattr vts={} idx={i} lang={:?}", ts.vts_number, s.language, ); } } /// Emit the ACTUAL per-physical-sub-stream AC-3 channel counts read off the VOB /// during the mux-time sub-stream probe (the Silence-of-the-Lambs wrong-stream /// fix). This is the ground truth the IFO nibble is compared against: each row /// is `sub_id=0x8x channels=N` for a physical `private_stream_1` AC-3 sub-stream /// whose first frame was decoded. An empty probe (scrambled / unreadable / short /// VOB) logs a single `probed=0` line so the absence is explicit in a bug log. /// /// Self-sufficiency: with `tag=dvd.aattr` (the IFO's declared sub_id + claimed /// channels) and these `tag=dvd.substream` rows (the physical reality), a bug /// log alone shows whether the ordinal `0x80` actually carries the declared /// channel layout — no disc needed to diagnose a wrong-substream rip. pub fn dump_dvd_substream_probe(title_id: u16, probed: &std::collections::BTreeMap) { if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) { return; } if probed.is_empty() { tracing::debug!( target: DIAG, "tag=dvd.substream title={title_id} probed=0 (no AC-3 sync in feature head — scrambled/unreadable/none)", ); return; } for (sub, ch) in probed { tracing::debug!( target: DIAG, "tag=dvd.substream title={title_id} sub_id=0x{sub:02X} channels={ch} (physical acmod read from VOB)", ); } } // ── MKV TrackEntry dump (the ACTUAL container elements written) ────────────── /// `true` when the `--log-level 3` diagnostic target is enabled. Hot-path /// callers (the opening-frame capture) check this once and skip all work when /// off, so a normal run pays nothing. pub fn diag_enabled() -> bool { tracing::enabled!(target: DIAG, tracing::Level::DEBUG) } /// Cap on the number of codecPrivate bytes rendered to hex in a `tag=mkv.track` /// line. The sequence header / avcC / hvcC prefix that matters for diagnosis /// (resolution, frame rate, profile) is at the front; a multi-KB blob past this /// is summarised as `..(+NB)` rather than flooding the log. const CODEC_PRIVATE_HEX_CAP: usize = 64; /// Render a track's codecPrivate as an uppercase-hex string for the diagnostic /// line, capped at [`CODEC_PRIVATE_HEX_CAP`] bytes (`..(+NB)` suffix beyond). /// `None` / empty → `"none"`. Pure (no logging) so it is directly unit-testable. fn codec_private_hex(cp: Option<&[u8]>) -> String { match cp { Some(b) if !b.is_empty() => { use std::fmt::Write; let shown = b.len().min(CODEC_PRIVATE_HEX_CAP); let mut s = String::with_capacity(shown * 2 + 8); for byte in &b[..shown] { let _ = write!(s, "{byte:02X}"); } if b.len() > CODEC_PRIVATE_HEX_CAP { let _ = write!(s, "..(+{}B)", b.len() - CODEC_PRIVATE_HEX_CAP); } s } _ => "none".to_string(), } } /// Frame the raw bytes of one captured opening frame for the `.opening.bin` side /// file: `[track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw bytes]`. Pure /// (no I/O) so the record layout is directly unit-testable; `record` appends the /// returned bytes to the side file. fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Vec { let mut rec = Vec::with_capacity(14 + data.len()); rec.push(track_idx as u8); rec.push(keyframe as u8); rec.extend_from_slice(&pts_ns.to_le_bytes()); rec.extend_from_slice(&(data.len() as u32).to_le_bytes()); rec.extend_from_slice(data); rec } /// Emit the MKV `TrackEntry` elements the muxer is about to WRITE for one /// track — the Windows-fps-class metadata (FlagInterlaced, FieldOrder, /// DefaultDuration, DefaultDecodedFieldDuration, Display dims) plus the /// codecPrivate as hex. With this row a bug log alone is enough to verify why /// Windows Explorer reports a given frame rate for an interlaced SD track: the /// container values that drive its fps derivation are all present, no disc and /// no MediaInfo needed. /// /// `track_number` is the 1-based MKV track number; `track` is the built /// [`crate::mux::mkv::MkvTrack`] whose fields map one-to-one onto the emitted /// elements (see `MkvMuxer::new`). No-op unless the diag target is on. pub(crate) fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) { if !diag_enabled() { return; } // codecPrivate as hex (capped so a multi-KB hvcC doesn't flood the log; the // sequence header / avcC prefix that matters for diagnosis is at the front). let cp = codec_private_hex(track.codec_private.as_deref()); let field_order = match track.field_order { crate::mux::ebml::FIELD_ORDER_TFF => "TFF", crate::mux::ebml::FIELD_ORDER_BFF => "BFF", _ => "—", }; // FlagInterlaced is only written for video tracks (1=interlaced/2=progressive); // report what the muxer will emit, or "—" for non-video tracks where the // element is omitted entirely. let interlaced = if track.track_type == crate::mux::ebml::TRACK_TYPE_VIDEO { if track.interlaced { "1(interlaced)" } else { "2(progressive)" } } else { "—" }; tracing::debug!( target: DIAG, "tag=mkv.track num={track_number} type={} codec={} flag_interlaced={interlaced} \ field_order={field_order} default_duration_ns={} field_duration_ns={} \ pixel={}x{} display={}x{} cp_len={} cp_hex={cp}", track.track_type, track.codec_id, track.default_duration_ns, track.field_duration_ns, track.pixel_width, track.pixel_height, track.display_width, track.display_height, track.codec_private.as_ref().map_or(0, |b| b.len()), ); } // ── Opening-frame capture (first ~N coded frames per track → side file) ────── /// Number of coded frames captured PER TRACK before the capture goes dormant. /// ~100 frames covers a DVD's first few seconds of every track (the /// opening-GOP / still-frame / menu window where mid-GOP open or PTS-floor bugs /// show up) while bounding the side file to a few MB even for HD I-frames. const OPENING_FRAMES_PER_TRACK: usize = 100; /// Captures the first [`OPENING_FRAMES_PER_TRACK`] coded frames of EACH track to /// a side file (`.opening.bin`) and logs a per-frame summary line, so an /// opening-GOP / menu / mid-GOP-open issue is diagnosable from a future log + /// side file WITHOUT the disc. Gated to `--log-level 3`: constructed only when /// the diag target is on, so a normal run never opens the file or records a byte. /// /// Side-file record framing (so a reader can split it back into frames): /// `[track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw frame bytes]`. pub struct OpeningCapture { file: std::fs::File, /// Frames captured so far, per track index. Capture for a track stops once /// its counter reaches [`OPENING_FRAMES_PER_TRACK`]. counts: Vec, } impl OpeningCapture { /// Open `.opening.bin` next to the MKV output. Returns `None` (no /// capture) when the diag target is off OR the side file can't be created — /// a diagnostic must never fail the rip. `track_count` sizes the per-track /// counters. pub fn new(output_path: &std::path::Path, track_count: usize) -> Option { if !diag_enabled() { return None; } let mut name = output_path.as_os_str().to_os_string(); name.push(".opening.bin"); match std::fs::File::create(&name) { Ok(file) => { tracing::debug!( target: DIAG, "tag=mkv.opening.open path={:?} per_track_cap={OPENING_FRAMES_PER_TRACK}", std::path::Path::new(&name), ); Some(Self { file, counts: vec![0; track_count], }) } Err(e) => { tracing::debug!( target: DIAG, "tag=mkv.opening.open path={:?} failed={e} (capture disabled, rip unaffected)", std::path::Path::new(&name), ); None } } } /// Record one coded frame for `track_idx` if that track is still under its /// per-track cap. Writes the framed raw bytes to the side file and logs a /// one-line summary. A write error disables further capture for the track /// (counter pinned to the cap) but never propagates — the rip is unaffected. pub fn record(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) { let Some(count) = self.counts.get_mut(track_idx) else { return; }; if *count >= OPENING_FRAMES_PER_TRACK { return; } use std::io::Write; let rec = frame_record(track_idx, pts_ns, keyframe, data); if let Err(e) = self.file.write_all(&rec) { // Stop trying on this track; a broken side file must not stall mux. *count = OPENING_FRAMES_PER_TRACK; tracing::debug!( target: DIAG, "tag=mkv.opening.frame track={track_idx} write_failed={e} (capture stopped for track)", ); return; } *count += 1; tracing::debug!( target: DIAG, "tag=mkv.opening.frame track={track_idx} n={count} type={} size={} pts_ns={pts_ns}", if keyframe { "key" } else { "delta" }, data.len(), ); } } // ── Disc-level dump (post-lowering: titles, streams, decisions, AACS) ──────── /// Emit the full scan diagnostic block for a built [`Disc`]. Terse, one line /// per row, under target `freemkv::diag` at DEBUG. No-op unless that target /// is enabled, so it costs nothing when `--log-level 3` is off. pub fn dump_disc(disc: &Disc) { if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) { return; } tracing::debug!( target: DIAG, "tag=disc vol={:?} format={:?} content={:?} cap_sectors={} layers={} titles={} encrypted={}", disc.volume_id, disc.format, disc.content_format, disc.capacity_sectors, disc.layers, disc.titles.len(), disc.encrypted, ); dump_aacs(disc); for (ti, title) in disc.titles.iter().enumerate() { dump_title(ti, title); } // freemkv's top-level DECISION: which title is the main feature. if let Some(main) = disc.titles.first() { tracing::debug!( target: DIAG, "tag=decision pick=main_feature title_idx=0 playlist={:?} dur={:.1}s \ size={}B clips={} reason=canonical_title_order(fits-disc, fewest-clips, longest, richest-audio)", main.playlist, main.duration_secs, main.size_bytes, main.clips.len(), ); } } fn dump_aacs(disc: &Disc) { let Some(a) = disc.aacs.as_ref() else { if disc.css.is_some() { tracing::debug!(target: DIAG, "tag=aacs none crypto=CSS(DVD)"); } else if disc.encrypted { tracing::debug!(target: DIAG, "tag=aacs none crypto=encrypted-no-keys"); } else { tracing::debug!(target: DIAG, "tag=aacs none crypto=clear"); } return; }; // CPS-unit / unit-key counts: at scan `unit_keys` is empty (keys are // resolved later); the unit-key count is the BE16 in the raw // Unit_Key_RO.inf if captured. Report both: resolved count and raw len. tracing::debug!( target: DIAG, "tag=aacs version={} bus_enc={} mkb_version={:?} disc_hash={} key_source={:?} \ vuk={} unit_keys_resolved={} uk_ro_bytes={} mkb_bytes={}", a.version, a.bus_encryption, a.mkb_version, a.disc_hash, a.key_source, a.vuk.is_some(), a.unit_keys.len(), a.uk_ro.len(), a.mkb.len(), ); } fn dump_title(ti: usize, title: &DiscTitle) { let (mut nv, mut na, mut ns) = (0u32, 0u32, 0u32); for s in &title.streams { match s { Stream::Video(_) => nv += 1, Stream::Audio(_) => na += 1, Stream::Subtitle(_) => ns += 1, } } tracing::debug!( target: DIAG, "tag=title idx={ti} playlist={:?} id={} dur={:.1}s size={}B clips={} \ extents={} chapters={} v={nv} a={na} s={ns} fmt={:?}", title.playlist, title.playlist_id, title.duration_secs, title.size_bytes, title.clips.len(), title.extents.len(), title.chapters.len(), title.content_format, ); // Per-clip rows (BD: PlayItem/CLPI; DVD has none). for (ci, c) in title.clips.iter().enumerate() { tracing::debug!( target: DIAG, "tag=clip title={ti} idx={ci} id={:?} in={} out={} dur={:.1}s src_packets={}", c.clip_id, c.in_time, c.out_time, c.duration_secs, c.source_packets, ); } // Per-extent rows (the sectors freemkv will actually rip — the bug-4 // decision is visible here: leading non-feature cells are already gone). for (ei, e) in title.extents.iter().enumerate() { tracing::debug!( target: DIAG, "tag=extent title={ti} idx={ei} start_lba={} sectors={}", e.start_lba, e.sector_count, ); } // freemkv's per-stream DECISIONS (what the muxer will write). for (si, s) in title.streams.iter().enumerate() { match s { Stream::Video(v) => tracing::debug!( target: DIAG, "tag=stream title={ti} idx={si} kind=video pid=0x{:04X} codec={:?} \ res={} interlaced={} fps={} std={} color={} hdr={} aspect={:?} secondary={}", v.pid, v.codec, res_str(v.resolution), v.resolution.is_interlaced(), fps_str(v.frame_rate), tv_system_str(v.frame_rate), color_str(v.color_space), hdr_str(v.hdr), v.display_aspect, v.secondary, ), Stream::Audio(a) => tracing::debug!( target: DIAG, "tag=stream title={ti} idx={si} kind=audio pid=0x{:04X} codec={:?} \ channels={}({}) sr={}Hz lang={:?} secondary={}", a.pid, a.codec, a.channels, channel_count(a.channels), sample_rate_hz(a.sample_rate), a.language, a.secondary, ), Stream::Subtitle(sub) => tracing::debug!( target: DIAG, "tag=stream title={ti} idx={si} kind=subtitle pid=0x{:04X} codec={:?} \ lang={:?} forced={}", sub.pid, sub.codec, sub.language, sub.forced, ), } } } #[cfg(test)] mod tests { use super::*; #[test] fn res_str_keeps_interlace_marker() { assert_eq!(res_str(Resolution::R576i), "576i"); assert_eq!(res_str(Resolution::R480i), "480i"); assert_eq!(res_str(Resolution::R2160p), "2160p"); } #[test] fn fps_and_tv_system() { assert_eq!(fps_str(FrameRate::F25), "25"); assert_eq!(tv_system_str(FrameRate::F25), "PAL"); assert_eq!(fps_str(FrameRate::F29_97), "29.97"); assert_eq!(tv_system_str(FrameRate::F29_97), "NTSC"); } #[test] fn color_and_hdr() { assert_eq!(color_str(ColorSpace::Bt470bg), "BT.470BG"); assert_eq!(color_str(ColorSpace::Bt2020), "BT.2020"); assert_eq!(hdr_str(HdrFormat::Hdr10), "HDR10"); assert_eq!(hdr_str(HdrFormat::DolbyVision), "DoVi"); assert_eq!(hdr_str(HdrFormat::Sdr), "SDR"); } #[test] fn channel_count_matches_layout() { assert_eq!(channel_count(AudioChannels::Mono), 1); assert_eq!(channel_count(AudioChannels::Stereo), 2); assert_eq!(channel_count(AudioChannels::Surround51), 6); assert_eq!(channel_count(AudioChannels::Surround71), 8); } #[test] fn sample_rate_hz_values() { assert_eq!(sample_rate_hz(SampleRate::S48), 48000); assert_eq!(sample_rate_hz(SampleRate::S96), 96000); } #[test] fn codec_private_hex_renders_caps_and_handles_empty() { // None / empty → "none" (no hex). The Windows-fps diagnosis only needs // the seq-header prefix, so render it but cap long blobs. assert_eq!(codec_private_hex(None), "none"); assert_eq!(codec_private_hex(Some(&[])), "none"); // Short blob: full uppercase hex, no suffix. An MPEG-2 seq header starts // 00 00 01 B3 — exactly what a reader greps for in a bug log. assert_eq!( codec_private_hex(Some(&[0x00, 0x00, 0x01, 0xB3])), "000001B3" ); // Over the cap: first CODEC_PRIVATE_HEX_CAP bytes + a "..(+NB)" summary. let big = vec![0xABu8; CODEC_PRIVATE_HEX_CAP + 5]; let s = codec_private_hex(Some(&big)); assert!(s.starts_with(&"AB".repeat(CODEC_PRIVATE_HEX_CAP)), "{s}"); assert!(s.ends_with("..(+5B)"), "{s}"); } #[test] fn frame_record_layout_is_parseable() { // The .opening.bin record framing must round-trip so a future tool can // split the side file back into frames without the disc: // [track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw bytes]. let data = [0xDEu8, 0xAD, 0xBE, 0xEF]; let rec = frame_record(2, -40_000_000, true, &data); assert_eq!(rec.len(), 14 + data.len()); assert_eq!(rec[0], 2, "track index"); assert_eq!(rec[1], 1, "keyframe flag"); assert_eq!( i64::from_le_bytes(rec[2..10].try_into().unwrap()), -40_000_000, "pts_ns survives (signed — opening back-anchor can be negative)" ); assert_eq!( u32::from_le_bytes(rec[10..14].try_into().unwrap()), 4, "len" ); assert_eq!(&rec[14..], &data, "raw frame bytes follow"); // A non-keyframe records the flag as 0. let delta = frame_record(0, 0, false, &[]); assert_eq!(delta[1], 0); assert_eq!(u32::from_le_bytes(delta[10..14].try_into().unwrap()), 0); } /// The cell row shows the raw category byte (0xNN) beside the decode, and /// the keep/drop verdict. A plain feature cell (0x00) is "keep"; a leading /// secondary-block cell flagged dropped reads "DROP". #[test] fn cell_row_shows_raw_byte_and_verdict() { let plain = crate::ifo::DvdCell { first_sector: 100, last_sector: 199, category: 0x00, duration_secs: 12.5, }; let row = dvd_cell_row(0, &plain, false); assert!(row.contains("cat=0x00"), "{row}"); assert!(row.contains("block_mode=0"), "{row}"); assert!(row.contains("first=100"), "{row}"); assert!(row.contains("last=199"), "{row}"); assert!(row.contains("dur=12.5s"), "{row}"); assert!(row.contains("keep(plain-feature)"), "{row}"); assert!(!row.contains("DROP"), "{row}"); // 0x90 = in-block cell of an angle block (block_mode=2, block_type=1), // shown dropped as a leading secondary piece. let sec = crate::ifo::DvdCell { first_sector: 0, last_sector: 9, category: 0x90, duration_secs: 1.0, }; let row = dvd_cell_row(0, &sec, true); assert!(row.contains("cat=0x90"), "{row}"); assert!(row.contains("block_mode=2"), "{row}"); assert!(row.contains("block_type=1"), "{row}"); assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}"); } }