Skip to main content

oxideav_dvd/
ifo.rs

1//! IFO body parser — Video Manager Information (VMGI), Video Title
2//! Set Information (VTSI), Program Chain Information (PGCI), Title
3//! Search Pointer Table (TT_SRPT), Part-of-Title Search Pointer Table
4//! (VTS_PTT_SRPT), Cell Address Table (VTS_C_ADT).
5//!
6//! Phase 2: structural decoding of the IFO files only — title list,
7//! chapter list, program-chain layout, cell sector ranges. No VOB
8//! demuxing, no cell concatenation, no virtual-machine command
9//! execution, no playback. Those are Phase 3.
10//!
11//! ## Sector / byte addressing convention
12//!
13//! IFO files are sequences of 2048-byte logical sectors. Field
14//! offsets in `VMGI_MAT` / `VTSI_MAT` that are described as "sector
15//! pointers" are sector indexes **relative to the start of the IFO
16//! file**, not absolute disc-LBA values. The "start sector" fields
17//! inside `TT_SRPT` entries, by contrast, are absolute disc LBAs
18//! (referenced to the whole disc, where `VIDEO_TS.IFO` lives at LBA
19//! 0 of that title set).
20//!
21//! All multi-byte integer fields are big-endian (network byte order).
22//!
23//! ## Clean-room references
24//!
25//! Material consulted while writing this module:
26//!
27//! - `docs/container/dvd/application/mpucoder-ifo.html`
28//!   (VMGI_MAT / VTSI_MAT field layout, sector-pointer offsets,
29//!   C_ADT / VOBU_ADMAP entry format).
30//! - `docs/container/dvd/application/mpucoder-ifo_vmg.html`
31//!   (TT_SRPT, VMGM_PGCI_UT, VMG_PTL_MAIT, VMG_VTS_ATRT).
32//! - `docs/container/dvd/application/mpucoder-ifo_vts.html`
33//!   (VTS_PTT_SRPT, VTS_PGCI, VTSM_PGCI_UT, VTS_TMAPTI).
34//! - `docs/container/dvd/application/mpucoder-pgc.html`
35//!   (PGC header at offset 0..0xEC, command table, program map,
36//!   cell playback information table, cell position information).
37//! - `docs/container/dvd/application/stnsoft-vmindx.html`
38//!   (cross-reference for VTS_C_ADT entry layout).
39//!
40//! Field layouts derive from the
41//! `docs/container/dvd/application/` references listed above.
42
43use crate::error::{Error, Result};
44
45/// Logical-sector size on a DVD-ROM (per ECMA-267 §1.7).
46pub const DVD_SECTOR: usize = 2048;
47
48/// Magic at byte 0 of `VIDEO_TS.IFO`.
49pub const VMG_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG";
50
51/// Magic at byte 0 of `VTS_xx_0.IFO`.
52pub const VTS_MAGIC: &[u8; 12] = b"DVDVIDEO-VTS";
53
54// ------------------------------------------------------------------
55// Common helpers
56// ------------------------------------------------------------------
57
58fn read_u16(buf: &[u8], off: usize) -> Result<u16> {
59    let slice = buf
60        .get(off..off + 2)
61        .ok_or(Error::InvalidUdf("ifo: u16 read past end"))?;
62    Ok(u16::from_be_bytes([slice[0], slice[1]]))
63}
64
65fn read_u32(buf: &[u8], off: usize) -> Result<u32> {
66    let slice = buf
67        .get(off..off + 4)
68        .ok_or(Error::InvalidUdf("ifo: u32 read past end"))?;
69    Ok(u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]]))
70}
71
72fn read_u8(buf: &[u8], off: usize) -> Result<u8> {
73    buf.get(off)
74        .copied()
75        .ok_or(Error::InvalidUdf("ifo: u8 read past end"))
76}
77
78// ------------------------------------------------------------------
79// PgcTime — BCD playback time + frame-rate bits
80// ------------------------------------------------------------------
81
82/// Playback time field used by `PGC_GI` and per-cell `C_PBI` entries.
83///
84/// Layout per mpucoder-pgc.html: 4 bytes — `hh:mm:ss:ff` in BCD, with
85/// bits 7 & 6 of the last byte encoding the frame rate. `11b` = 30 fps
86/// (NTSC drop / non-drop), `01b` = 25 fps (PAL). `10b` and `00b` are
87/// declared illegal by the spec — we surface them as
88/// [`FrameRate::Illegal`] rather than rejecting outright since some
89/// authoring tools emit zero-time placeholder fields.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct PgcTime {
92    pub hours: u8,
93    pub minutes: u8,
94    pub seconds: u8,
95    pub frames: u8,
96    pub frame_rate: FrameRate,
97}
98
99/// Frame-rate encoding used by [`PgcTime`].
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum FrameRate {
102    /// `00b` — illegal per spec, but present in some authoring outputs.
103    Illegal,
104    /// `01b` — 25 fps (PAL).
105    Pal25,
106    /// `10b` — illegal per spec.
107    Reserved,
108    /// `11b` — 30 fps (NTSC; the spec lumps drop/non-drop together).
109    Ntsc30,
110}
111
112impl PgcTime {
113    /// Decode a 4-byte BCD playback time field.
114    pub fn from_bytes(bytes: [u8; 4]) -> Self {
115        fn bcd(b: u8) -> u8 {
116            ((b >> 4) & 0x0F) * 10 + (b & 0x0F)
117        }
118        let hours = bcd(bytes[0]);
119        let minutes = bcd(bytes[1]);
120        let seconds = bcd(bytes[2]);
121        // Frames byte: bits 7+6 = frame-rate; bits 5..0 = BCD frames.
122        // Per mpucoder-pgc.html the frames nibble pair is itself BCD,
123        // but only the low 6 bits encode frames (so the tens digit is
124        // 0..3, sufficient for max 29 frames at 30 fps).
125        let frame_rate = match (bytes[3] >> 6) & 0x03 {
126            0b00 => FrameRate::Illegal,
127            0b01 => FrameRate::Pal25,
128            0b10 => FrameRate::Reserved,
129            0b11 => FrameRate::Ntsc30,
130            _ => unreachable!(),
131        };
132        let f_lo = bytes[3] & 0x0F;
133        let f_hi = (bytes[3] >> 4) & 0x03;
134        let frames = f_hi * 10 + f_lo;
135        Self {
136            hours,
137            minutes,
138            seconds,
139            frames,
140            frame_rate,
141        }
142    }
143
144    /// Total integer seconds (frames truncated, frame-rate ignored).
145    /// Convenient when callers just need a "ballpark length" without
146    /// the per-frame rounding.
147    pub fn total_seconds(self) -> u32 {
148        u32::from(self.hours) * 3600 + u32::from(self.minutes) * 60 + u32::from(self.seconds)
149    }
150
151    /// Convert this BCD field to absolute nanoseconds.
152    ///
153    /// `hh:mm:ss` resolves to whole seconds via [`Self::total_seconds`],
154    /// then the `frames` count is scaled by the per-rate frame period
155    /// (33,333,333 ns at 30 fps, 40,000,000 ns at 25 fps). Rational
156    /// arithmetic — `(frames * 1e9) / fps` — caps the per-call truncation
157    /// at ±1 ns instead of accumulating ~5e-9 per frame.
158    ///
159    /// Spec-`Illegal` and reserved-`10b` frame-rate codes contribute
160    /// only the whole-second portion; the fractional frames are
161    /// dropped because the spec defines no rate to scale them by.
162    /// This keeps a malformed BCD field from poisoning a chapter
163    /// length with a wildly-wrong scaled value, while still letting a
164    /// caller surface a "best-effort" duration.
165    ///
166    /// Layout per `mpucoder-pgc.html` (PgcTime) and
167    /// `mpucoder-dsi_pkt.html` `c_eltm` (same BCD shape).
168    pub fn to_nanoseconds(self) -> u64 {
169        let secs = u64::from(self.total_seconds());
170        let secs_ns = secs.saturating_mul(1_000_000_000);
171        let frames_ns = match self.frame_rate {
172            FrameRate::Ntsc30 => u64::from(self.frames).saturating_mul(1_000_000_000) / 30,
173            FrameRate::Pal25 => u64::from(self.frames).saturating_mul(1_000_000_000) / 25,
174            FrameRate::Illegal | FrameRate::Reserved => 0,
175        };
176        secs_ns.saturating_add(frames_ns)
177    }
178}
179
180// ------------------------------------------------------------------
181// VMGI_MAT — Video Manager Information Management Table
182// ------------------------------------------------------------------
183
184/// Parsed VMGI_MAT (the first 0x200 bytes of `VIDEO_TS.IFO`).
185///
186/// Fields are surfaced in the order they appear in mpucoder-ifo.html's
187/// "VMG IFO Contents" column. Sector-pointer fields are kept as-is
188/// (`0` denotes "table absent" per spec).
189#[derive(Debug, Clone)]
190pub struct VmgIfo {
191    /// Last sector of the VMG set (last sector of `VIDEO_TS.BUP`).
192    pub last_sector_vmg_set: u32,
193    /// Last sector of `VIDEO_TS.IFO`.
194    pub last_sector_ifo: u32,
195    /// VMGI version number, packed as `(major << 4) | minor` in the
196    /// low byte of a 16-bit BE field (mpucoder-ifo.html "Version
197    /// Number"). DVD-Video is typically `0x10` (1.0) or `0x11` (1.1).
198    pub version: u16,
199    /// VMG category (region mask in byte 1; rest reserved).
200    pub vmg_category: u32,
201    /// Number of volumes in this title set (e.g. 1 for single-volume
202    /// discs; >1 for jukebox-style multi-side authoring).
203    pub number_of_volumes: u16,
204    /// Volume number (1-based) within the set above.
205    pub volume_number: u16,
206    /// Side ID (0 = side A, 1 = side B for double-sided discs).
207    pub side_id: u8,
208    /// Number of Video Title Sets (1..=99).
209    pub number_of_title_sets: u16,
210    /// Provider ID (32 ASCII bytes, NUL-padded).
211    pub provider_id: String,
212    /// Last byte address of `VMGI_MAT` itself.
213    pub vmgi_mat_end: u32,
214    /// Start address (byte offset) of First-Play PGC. `0` if absent.
215    pub fp_pgc_addr: u32,
216    /// Sector pointer to the VMG menu VOB (`0` if no menu).
217    pub menu_vob_sector: u32,
218    /// Sector pointer to TT_SRPT. Mandatory; always non-zero on a
219    /// well-formed disc.
220    pub tt_srpt_sector: u32,
221    /// Sector pointer to VMGM_PGCI_UT. `0` if no VMG menu.
222    pub vmgm_pgci_ut_sector: u32,
223    /// Sector pointer to VMG_PTL_MAIT. `0` if no parental management.
224    pub ptl_mait_sector: u32,
225    /// Sector pointer to VMG_VTS_ATRT.
226    pub vts_atrt_sector: u32,
227    /// Sector pointer to VMG_TXTDT_MG (disc text data). `0` if absent.
228    pub txtdt_mg_sector: u32,
229    /// Sector pointer to VMGM_C_ADT (menu cell address table).
230    pub vmgm_c_adt_sector: u32,
231    /// Sector pointer to VMGM_VOBU_ADMAP (menu VOBU address map).
232    pub vmgm_vobu_admap_sector: u32,
233    /// VMGM (First-Play / VMG menu) stream attributes at
234    /// 0x0100..0x015C. Empty when the buffer was too short to
235    /// cover that region.
236    pub menu_attributes: MenuAttributes,
237}
238
239impl VmgIfo {
240    /// Parse a `VIDEO_TS.IFO` byte buffer. The buffer must cover at
241    /// least the VMGI_MAT region (the first 0x200 bytes).
242    pub fn parse(buf: &[u8]) -> Result<Self> {
243        if buf.len() < 0x200 {
244            return Err(Error::InvalidUdf("VMGI_MAT: buffer shorter than 0x200"));
245        }
246        if &buf[0..12] != VMG_MAGIC {
247            return Err(Error::InvalidUdf("VMGI_MAT: bad magic"));
248        }
249        let last_sector_vmg_set = read_u32(buf, 0x000C)?;
250        let last_sector_ifo = read_u32(buf, 0x001C)?;
251        let version = read_u16(buf, 0x0020)?;
252        let vmg_category = read_u32(buf, 0x0022)?;
253        let number_of_volumes = read_u16(buf, 0x0026)?;
254        let volume_number = read_u16(buf, 0x0028)?;
255        let side_id = read_u8(buf, 0x002A)?;
256        let number_of_title_sets = read_u16(buf, 0x003E)?;
257        let provider_id_raw = &buf[0x0040..0x0060];
258        let provider_id = decode_ascii_trim(provider_id_raw);
259        let vmgi_mat_end = read_u32(buf, 0x0080)?;
260        let fp_pgc_addr = read_u32(buf, 0x0084)?;
261        let menu_vob_sector = read_u32(buf, 0x00C0)?;
262        let tt_srpt_sector = read_u32(buf, 0x00C4)?;
263        let vmgm_pgci_ut_sector = read_u32(buf, 0x00C8)?;
264        let ptl_mait_sector = read_u32(buf, 0x00CC)?;
265        let vts_atrt_sector = read_u32(buf, 0x00D0)?;
266        let txtdt_mg_sector = read_u32(buf, 0x00D4)?;
267        let vmgm_c_adt_sector = read_u32(buf, 0x00D8)?;
268        let vmgm_vobu_admap_sector = read_u32(buf, 0x00DC)?;
269        let menu_attributes = parse_menu_attribute_block(buf, 0x0100)?;
270
271        Ok(Self {
272            last_sector_vmg_set,
273            last_sector_ifo,
274            version,
275            vmg_category,
276            number_of_volumes,
277            volume_number,
278            side_id,
279            number_of_title_sets,
280            provider_id,
281            vmgi_mat_end,
282            fp_pgc_addr,
283            menu_vob_sector,
284            tt_srpt_sector,
285            vmgm_pgci_ut_sector,
286            ptl_mait_sector,
287            vts_atrt_sector,
288            txtdt_mg_sector,
289            vmgm_c_adt_sector,
290            vmgm_vobu_admap_sector,
291            menu_attributes,
292        })
293    }
294}
295
296fn decode_ascii_trim(buf: &[u8]) -> String {
297    let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
298    String::from_utf8_lossy(&buf[..end]).trim_end().to_string()
299}
300
301// ------------------------------------------------------------------
302// TT_SRPT — Title Search Pointer Table (VMG-side)
303// ------------------------------------------------------------------
304
305/// One entry of the VMG-side TT_SRPT (title search pointer table).
306///
307/// Per mpucoder-ifo_vmg.html "TT_SRPT" each entry is 12 bytes and
308/// indexes the disc-global "title number" to the (title-set, title-
309/// in-title-set) pair plus the VTS's start sector on the disc.
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub struct DvdTitleEntry {
312    /// Title type byte (jump/link/call permission bits — see spec).
313    pub title_type: u8,
314    /// Number of angles (1..=9).
315    pub angle_count: u8,
316    /// Number of chapters / parts-of-title (PTTs) in this title.
317    pub chapter_count: u16,
318    /// Parental management mask (16-bit; bit N set = blocked at level N).
319    pub parental_mask: u16,
320    /// VTS number (1..=99) this title lives in.
321    pub vts_number: u8,
322    /// Title number within that VTS.
323    pub vts_title_number: u8,
324    /// Start sector of the VTS (whole-disc-relative LBA).
325    pub vts_start_sector: u32,
326}
327
328impl DvdTitleEntry {
329    /// 2-bit UOP-prohibition subset packed into the low bits of
330    /// [`Self::title_type`].
331    ///
332    /// Per `docs/container/dvd/application/mpucoder-uops.html`,
333    /// TT_SRPT carries only UOP-0 (`TimePlayOrSearch`) and UOP-1
334    /// (`PttPlayOrSearch`); they live in the two low bits of
335    /// `title_type`. The remaining `title_type` bits encode the
336    /// title's jump/link/call permission flags per
337    /// `mpucoder-ifo_vmg.html` and stay outside the UOP surface.
338    #[inline]
339    pub fn uop_mask(&self) -> crate::uops::UopMask {
340        crate::uops::title_type_uop_mask(self.title_type)
341    }
342
343    /// `true` when `op` is **not** prohibited at the TT_SRPT level
344    /// for this title. Only the two TT_SRPT-applicable ops
345    /// (`TimePlayOrSearch`, `PttPlayOrSearch`) ever yield a `false`
346    /// here; every other op returns `true` because TT_SRPT has no
347    /// bit to encode them. The PGC-level and PCI-VOBU-level masks
348    /// still need to be consulted via
349    /// [`crate::uops::UopMask::merge_or`].
350    #[inline]
351    pub fn is_user_op_allowed(&self, op: crate::uops::UserOp) -> bool {
352        self.uop_mask().is_allowed(op)
353    }
354}
355
356/// Parsed TT_SRPT body — 8-byte header plus N × 12-byte entries.
357#[derive(Debug, Clone)]
358pub struct TtSrpt {
359    /// Number of titles (= entry count).
360    pub title_count: u16,
361    /// `end_address` field (last byte of last entry, relative to TT_SRPT start).
362    pub end_address: u32,
363    /// Parsed entries.
364    pub entries: Vec<DvdTitleEntry>,
365}
366
367impl TtSrpt {
368    /// Parse a TT_SRPT byte buffer. Buffer must include the 8-byte
369    /// header and at least `title_count * 12` entry bytes.
370    pub fn parse(buf: &[u8]) -> Result<Self> {
371        if buf.len() < 8 {
372            return Err(Error::InvalidUdf("TT_SRPT: shorter than 8-byte header"));
373        }
374        let title_count = read_u16(buf, 0)?;
375        let end_address = read_u32(buf, 4)?;
376        let needed = 8usize.saturating_add(usize::from(title_count) * 12);
377        if buf.len() < needed {
378            return Err(Error::InvalidUdf(
379                "TT_SRPT: buffer shorter than title_count*12",
380            ));
381        }
382        let mut entries = Vec::with_capacity(usize::from(title_count));
383        for i in 0..usize::from(title_count) {
384            let base = 8 + i * 12;
385            entries.push(DvdTitleEntry {
386                title_type: read_u8(buf, base)?,
387                angle_count: read_u8(buf, base + 1)?,
388                chapter_count: read_u16(buf, base + 2)?,
389                parental_mask: read_u16(buf, base + 4)?,
390                vts_number: read_u8(buf, base + 6)?,
391                vts_title_number: read_u8(buf, base + 7)?,
392                vts_start_sector: read_u32(buf, base + 8)?,
393            });
394        }
395        Ok(Self {
396            title_count,
397            end_address,
398            entries,
399        })
400    }
401}
402
403// ------------------------------------------------------------------
404// Stream attribute extension blocks
405//
406// mpucoder-ifo.html documents a shared attribute layout that lives
407// at fixed offsets inside both `VMGI_MAT` (one block at 0x0100..
408// 0x015C covering the VMGM menu VOBS) and `VTSI_MAT` (the menu
409// block at 0x0100..0x015C plus the title-content block at 0x0200..
410// 0x0318 plus the 8×24-byte multichannel-extension table at
411// 0x0318..0x03D8). Each block is a self-contained `(video, audio
412// list, sub-picture list)` triple; the multichannel-extension
413// table is karaoke-only and only present on the VTS title side.
414// ------------------------------------------------------------------
415
416/// MPEG coding mode field of `<a name="vidatt">video attributes</a>`.
417/// (mpucoder-ifo.html byte 0 bits 7..6 of the 2-byte video-attr field.)
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum VideoCodingMode {
420    Mpeg1,
421    Mpeg2,
422}
423
424/// Display standard — bits 5..4 of byte 0.
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum VideoStandard {
427    Ntsc,
428    Pal,
429}
430
431/// Display aspect ratio — bits 3..2 of byte 0. The "1" and "2"
432/// values are reserved by the spec; we surface them as
433/// [`VideoAspectRatio::Reserved`] rather than rejecting outright.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum VideoAspectRatio {
436    /// `00b` — 4:3 frame, no display-mode pulldown.
437    Ratio4x3,
438    /// `11b` — 16:9 frame, anamorphic or letterboxed delivery.
439    Ratio16x9,
440    /// `01b` / `10b` — reserved by the spec.
441    Reserved(u8),
442}
443
444/// Decoded NTSC / PAL pixel resolution — byte 1 bits 5..3 of the
445/// 2-byte video-attr field. The spec encodes resolution as a 3-bit
446/// index whose meaning depends on the standard byte; we resolve it
447/// to absolute pixel dimensions for the caller.
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum VideoResolution {
450    /// Full-D1 — `0` index. 720×480 (NTSC) / 720×576 (PAL).
451    FullD1,
452    /// Three-quarter D1 — `1` index. 704×480 (NTSC) / 704×576 (PAL).
453    ThreeQuarterD1,
454    /// Half-D1 — `2` index. 352×480 (NTSC) / 352×576 (PAL).
455    HalfD1,
456    /// SIF — `3` index. 352×240 (NTSC) / 352×288 (PAL).
457    Sif,
458    /// `4..=7` — reserved by the spec.
459    Reserved(u8),
460}
461
462impl VideoResolution {
463    /// Decoded `(width, height)` in pixels for this resolution code +
464    /// the parent's `VideoStandard`. Returns `None` for reserved
465    /// codes (caller can fall back to the raw resolution index from
466    /// [`VideoAttributes::resolution_code`]).
467    pub fn dimensions(self, standard: VideoStandard) -> Option<(u16, u16)> {
468        let h = match standard {
469            VideoStandard::Ntsc => 480,
470            VideoStandard::Pal => 576,
471        };
472        let w = match self {
473            VideoResolution::FullD1 => 720,
474            VideoResolution::ThreeQuarterD1 => 704,
475            VideoResolution::HalfD1 => 352,
476            VideoResolution::Sif => 352,
477            VideoResolution::Reserved(_) => return None,
478        };
479        let h = if matches!(self, VideoResolution::Sif) && matches!(standard, VideoStandard::Ntsc) {
480            240
481        } else if matches!(self, VideoResolution::Sif) && matches!(standard, VideoStandard::Pal) {
482            288
483        } else {
484            h
485        };
486        Some((w, h))
487    }
488}
489
490/// Decoded 2-byte VMGM / VTSM / VTS video-attribute field.
491///
492/// mpucoder-ifo.html "Video Attributes" lays the field out as:
493///
494/// ```text
495///   byte 0:
496///     bit 7..6  coding mode (00=MPEG-1, 01=MPEG-2)
497///     bit 5..4  standard (00=NTSC, 01=PAL)
498///     bit 3..2  aspect ratio (00=4:3, 11=16:9, 01/10 reserved)
499///     bit 1     1 = automatic pan/scan disallowed
500///     bit 0     1 = automatic letterbox disallowed
501///   byte 1:
502///     bit 7     CC for line 21 field 1 in GOP (NTSC only)
503///     bit 6     CC for line 21 field 2 in GOP (NTSC only)
504///     bit 5..3  resolution index (see VideoResolution)
505///     bit 2     1 = letterboxed source
506///     bit 1     reserved
507///     bit 0     PAL only: 0 = camera, 1 = film
508/// ```
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub struct VideoAttributes {
511    /// Raw 2-byte field, kept for fidelity / round-trip diagnostics.
512    pub raw: [u8; 2],
513    pub coding_mode: VideoCodingMode,
514    pub standard: VideoStandard,
515    pub aspect_ratio: VideoAspectRatio,
516    /// `true` when `byte0 bit 1` is set — pan/scan delivery
517    /// disabled at the disc level even for 4:3 displays.
518    pub pan_scan_disallowed: bool,
519    /// `true` when `byte0 bit 0` is set — letterbox delivery
520    /// disabled at the disc level.
521    pub letterbox_disallowed: bool,
522    /// `true` when `byte1 bit 7` is set — line-21 closed captioning
523    /// is present on field 1 of every GOP (NTSC only; ignored on PAL).
524    pub line21_field1_cc: bool,
525    /// `true` when `byte1 bit 6` is set — closed captioning on field 2.
526    pub line21_field2_cc: bool,
527    /// Raw 3-bit resolution index — see [`VideoResolution`] for the
528    /// decoded form.
529    pub resolution_code: u8,
530    pub resolution: VideoResolution,
531    /// `true` when the source itself is letterboxed (separate from
532    /// the delivery-mode "letterbox disallowed" bit).
533    pub letterboxed_source: bool,
534    /// PAL-only: `false` = camera-captured, `true` = film-source
535    /// (progressive at 24 fps, telecined to 25 fps). Always `false`
536    /// on NTSC.
537    pub film_source_pal: bool,
538}
539
540impl VideoAttributes {
541    /// Parse a 2-byte video-attribute field.
542    pub fn parse(buf: &[u8; 2]) -> Self {
543        let b0 = buf[0];
544        let b1 = buf[1];
545        let coding_mode = match (b0 >> 6) & 0b11 {
546            0 => VideoCodingMode::Mpeg1,
547            _ => VideoCodingMode::Mpeg2,
548        };
549        let standard = match (b0 >> 4) & 0b11 {
550            0 => VideoStandard::Ntsc,
551            _ => VideoStandard::Pal,
552        };
553        let aspect_ratio = match (b0 >> 2) & 0b11 {
554            0 => VideoAspectRatio::Ratio4x3,
555            3 => VideoAspectRatio::Ratio16x9,
556            x => VideoAspectRatio::Reserved(x),
557        };
558        let resolution_code = (b1 >> 3) & 0b111;
559        let resolution = match resolution_code {
560            0 => VideoResolution::FullD1,
561            1 => VideoResolution::ThreeQuarterD1,
562            2 => VideoResolution::HalfD1,
563            3 => VideoResolution::Sif,
564            x => VideoResolution::Reserved(x),
565        };
566        Self {
567            raw: *buf,
568            coding_mode,
569            standard,
570            aspect_ratio,
571            pan_scan_disallowed: (b0 & 0b0000_0010) != 0,
572            letterbox_disallowed: (b0 & 0b0000_0001) != 0,
573            line21_field1_cc: (b1 & 0b1000_0000) != 0,
574            line21_field2_cc: (b1 & 0b0100_0000) != 0,
575            resolution_code,
576            resolution,
577            letterboxed_source: (b1 & 0b0000_0100) != 0,
578            film_source_pal: (b1 & 0b0000_0001) != 0,
579        }
580    }
581}
582
583/// Audio coding mode — byte 0 bits 7..5 of the 8-byte audio-attr
584/// field. `0` = AC-3, `2` = MPEG-1, `3` = MPEG-2 extended, `4` = LPCM,
585/// `6` = DTS. Codes `1`, `5`, `7` are reserved by the spec.
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587pub enum AudioCodingMode {
588    Ac3,
589    Mpeg1,
590    Mpeg2Ext,
591    Lpcm,
592    Dts,
593    Reserved(u8),
594}
595
596/// Application mode — byte 0 bits 1..0. `0` = unspecified,
597/// `1` = karaoke (multichannel extension applies), `2` = surround
598/// (Dolby-Surround-suitable bit lives in byte 7).
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum AudioApplicationMode {
601    Unspecified,
602    Karaoke,
603    Surround,
604    Reserved(u8),
605}
606
607/// Audio quantization / dynamic-range-control field — byte 1 bits
608/// 7..6. The interpretation switches with the coding mode:
609/// LPCM uses the field as a sample-depth selector (16 / 20 / 24 bps),
610/// MPEG-1/2 uses it as a DRC flag.
611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
612pub enum AudioQuantizationDrc {
613    /// LPCM-only: 16 bps per sample.
614    Lpcm16,
615    /// LPCM-only: 20 bps per sample.
616    Lpcm20,
617    /// LPCM-only: 24 bps per sample.
618    Lpcm24,
619    /// MPEG-only: dynamic-range control absent.
620    NoDrc,
621    /// MPEG-only: dynamic-range control present.
622    Drc,
623    /// Field present but caller didn't supply the coding mode
624    /// hint — surface the raw 2-bit value.
625    Raw(u8),
626}
627
628/// Language type — byte 0 bits 3..2. `0` = unspecified (bytes 2..=4
629/// reserved), `1` = ISO-639 language code present in bytes 2..=4.
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631pub enum AudioLanguageType {
632    Unspecified,
633    Iso639,
634    Reserved(u8),
635}
636
637/// Decoded 8-byte audio-attribute field.
638///
639/// Layout per mpucoder-ifo.html "Audio Attributes":
640///
641/// ```text
642///   byte 0:
643///     bit 7..5  coding mode (0=AC3, 2=MPEG-1, 3=MPEG-2ext, 4=LPCM, 6=DTS)
644///     bit 4     multichannel-extension present (karaoke)
645///     bit 3..2  language type (0=unspecified, 1=ISO-639 in bytes 2..=4)
646///     bit 1..0  application mode (0=unspec, 1=karaoke, 2=surround)
647///   byte 1:
648///     bit 7..6  quantization / DRC (interpretation switches on coding mode)
649///     bit 5..4  sample-rate selector (only `0` = 48 kHz defined)
650///     bit 3     reserved
651///     bit 2..0  channels - 1   (so `1` = stereo, `5` = 5.1)
652///   bytes 2..=3:  ISO-639 two-letter language code (if `language_type == Iso639`)
653///   byte 4:       reserved for language-code extension
654///   byte 5:       code-extension byte — `0..=4` per `SPRM #17`
655///   byte 6:       reserved
656///   byte 7:       Application-information byte (karaoke channel
657///                 assignment or surround Dolby-suitable bit, per
658///                 the application mode)
659/// ```
660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
661pub struct AudioAttributes {
662    pub raw: [u8; 8],
663    pub coding_mode: AudioCodingMode,
664    /// `true` when the karaoke multichannel-extension entry for this
665    /// stream is populated. Always `false` outside karaoke titles.
666    pub multichannel_extension_present: bool,
667    pub language_type: AudioLanguageType,
668    pub application_mode: AudioApplicationMode,
669    pub quantization: AudioQuantizationDrc,
670    /// Sample-rate index (only `0` = 48 kHz is defined; any other
671    /// value is reserved).
672    pub sample_rate_code: u8,
673    /// Channel count, post-decoding the `channels - 1` field.
674    pub channel_count: u8,
675    /// Two-character ISO-639 language code, or empty when
676    /// `language_type` is `Unspecified`.
677    pub language_code: [u8; 2],
678    pub code_extension: u8,
679    /// Raw application-information byte at offset 7. Karaoke and
680    /// surround application modes both pack secondary flags in here;
681    /// callers can decode them with the helpers below.
682    pub application_info: u8,
683}
684
685impl AudioAttributes {
686    /// Parse an 8-byte audio-attribute field.
687    pub fn parse(buf: &[u8; 8]) -> Self {
688        let b0 = buf[0];
689        let b1 = buf[1];
690        let coding_mode_raw = (b0 >> 5) & 0b111;
691        let coding_mode = match coding_mode_raw {
692            0 => AudioCodingMode::Ac3,
693            2 => AudioCodingMode::Mpeg1,
694            3 => AudioCodingMode::Mpeg2Ext,
695            4 => AudioCodingMode::Lpcm,
696            6 => AudioCodingMode::Dts,
697            x => AudioCodingMode::Reserved(x),
698        };
699        let language_type = match (b0 >> 2) & 0b11 {
700            0 => AudioLanguageType::Unspecified,
701            1 => AudioLanguageType::Iso639,
702            x => AudioLanguageType::Reserved(x),
703        };
704        let application_mode = match b0 & 0b11 {
705            0 => AudioApplicationMode::Unspecified,
706            1 => AudioApplicationMode::Karaoke,
707            2 => AudioApplicationMode::Surround,
708            x => AudioApplicationMode::Reserved(x),
709        };
710        let quant_raw = (b1 >> 6) & 0b11;
711        let quantization = match coding_mode {
712            AudioCodingMode::Lpcm => match quant_raw {
713                0 => AudioQuantizationDrc::Lpcm16,
714                1 => AudioQuantizationDrc::Lpcm20,
715                2 => AudioQuantizationDrc::Lpcm24,
716                _ => AudioQuantizationDrc::Raw(quant_raw),
717            },
718            AudioCodingMode::Mpeg1 | AudioCodingMode::Mpeg2Ext => match quant_raw {
719                0 => AudioQuantizationDrc::NoDrc,
720                1 => AudioQuantizationDrc::Drc,
721                _ => AudioQuantizationDrc::Raw(quant_raw),
722            },
723            _ => AudioQuantizationDrc::Raw(quant_raw),
724        };
725        Self {
726            raw: *buf,
727            coding_mode,
728            multichannel_extension_present: (b0 & 0b0001_0000) != 0,
729            language_type,
730            application_mode,
731            quantization,
732            sample_rate_code: (b1 >> 4) & 0b11,
733            channel_count: (b1 & 0b0000_0111).saturating_add(1),
734            language_code: [buf[2], buf[3]],
735            code_extension: buf[5],
736            application_info: buf[7],
737        }
738    }
739
740    /// Decoded sample rate in hertz. Returns `Some(48_000)` for
741    /// `sample_rate_code == 0` (the only defined value) and `None`
742    /// for the reserved codes.
743    pub fn sample_rate_hz(self) -> Option<u32> {
744        match self.sample_rate_code {
745            0 => Some(48_000),
746            _ => None,
747        }
748    }
749
750    /// Surround application mode only: `true` when byte 7 bit 3 is
751    /// set (Dolby-Surround-decodable downmix). Always `false`
752    /// outside `AudioApplicationMode::Surround`.
753    pub fn dolby_surround_suitable(self) -> bool {
754        matches!(self.application_mode, AudioApplicationMode::Surround)
755            && (self.application_info & 0b0000_1000) != 0
756    }
757
758    /// Karaoke application mode only — channel-assignment index
759    /// from byte 7 bits 6..4. The spec maps:
760    /// `2` = 2/0 L,R / `3` = 3/0 L,M,R / `4` = 2/1 L,R,V1 /
761    /// `5` = 3/1 L,M,R,V1 / `6` = 2/2 L,R,V1,V2 / `7` = 3/2 L,M,R,V1,V2.
762    /// `0` and `1` are flagged "not valid" by the spec.
763    pub fn karaoke_channel_assignment(self) -> Option<u8> {
764        if matches!(self.application_mode, AudioApplicationMode::Karaoke) {
765            Some((self.application_info >> 4) & 0b0000_0111)
766        } else {
767            None
768        }
769    }
770
771    /// Karaoke version index — byte 7 bits 3..2.
772    pub fn karaoke_version(self) -> Option<u8> {
773        if matches!(self.application_mode, AudioApplicationMode::Karaoke) {
774            Some((self.application_info >> 2) & 0b11)
775        } else {
776            None
777        }
778    }
779
780    /// Karaoke MC-intro flag — byte 7 bit 1.
781    pub fn karaoke_mc_intro_present(self) -> Option<bool> {
782        if matches!(self.application_mode, AudioApplicationMode::Karaoke) {
783            Some((self.application_info & 0b10) != 0)
784        } else {
785            None
786        }
787    }
788
789    /// Karaoke solo / duet flag — byte 7 bit 0 (`0` = solo, `1` = duet).
790    pub fn karaoke_duet(self) -> Option<bool> {
791        if matches!(self.application_mode, AudioApplicationMode::Karaoke) {
792            Some((self.application_info & 0b01) != 0)
793        } else {
794            None
795        }
796    }
797}
798
799/// Sub-picture coding mode — byte 0 bits 7..5 of the 6-byte sub-
800/// picture-attribute field. Only `0` (2-bit RLE) is defined.
801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum SubpictureCodingMode {
803    Rle2Bit,
804    Reserved(u8),
805}
806
807/// Language type — same as the audio variant.
808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
809pub enum SubpictureLanguageType {
810    Unspecified,
811    Iso639,
812    Reserved(u8),
813}
814
815/// Decoded 6-byte sub-picture attribute field.
816///
817/// Layout per mpucoder-ifo.html "Subpicture Attributes":
818///
819/// ```text
820///   byte 0:
821///     bit 7..5  coding mode (0 = 2-bit RLE)
822///     bit 4..2  reserved
823///     bit 1..0  language type
824///   byte 1:     reserved
825///   bytes 2..=3: ISO-639 two-letter language code
826///   byte 4:     reserved for language-code extension
827///   byte 5:     code extension — see SPRM #19
828/// ```
829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
830pub struct SubpictureAttributes {
831    pub raw: [u8; 6],
832    pub coding_mode: SubpictureCodingMode,
833    pub language_type: SubpictureLanguageType,
834    pub language_code: [u8; 2],
835    pub code_extension: u8,
836}
837
838impl SubpictureAttributes {
839    /// Parse a 6-byte sub-picture-attribute field.
840    pub fn parse(buf: &[u8; 6]) -> Self {
841        let b0 = buf[0];
842        let coding_mode = match (b0 >> 5) & 0b111 {
843            0 => SubpictureCodingMode::Rle2Bit,
844            x => SubpictureCodingMode::Reserved(x),
845        };
846        let language_type = match b0 & 0b11 {
847            0 => SubpictureLanguageType::Unspecified,
848            1 => SubpictureLanguageType::Iso639,
849            x => SubpictureLanguageType::Reserved(x),
850        };
851        Self {
852            raw: *buf,
853            coding_mode,
854            language_type,
855            language_code: [buf[2], buf[3]],
856            code_extension: buf[5],
857        }
858    }
859}
860
861/// One 8-byte karaoke multichannel-extension entry, decoded per
862/// mpucoder-ifo.html "MultiChannel Extension - Karaoke mode".
863///
864/// Bytes 0x05..=0x17 are reserved-zero and absorbed silently.
865#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
866pub struct McExtensionEntry {
867    pub raw: [u8; 8],
868    /// Byte 0 bit 0 — guide melody on audio channel 0 of the
869    /// downmix is present.
870    pub ach0_guide_melody: bool,
871    /// Byte 1 bit 0 — guide melody on audio channel 1.
872    pub ach1_guide_melody: bool,
873    /// Byte 2 bit 3 — guide vocal 1 on audio channel 2.
874    pub ach2_guide_vocal_1: bool,
875    /// Byte 2 bit 2 — guide vocal 2 on audio channel 2.
876    pub ach2_guide_vocal_2: bool,
877    /// Byte 2 bit 1 — guide melody 1 on audio channel 2.
878    pub ach2_guide_melody_1: bool,
879    /// Byte 2 bit 0 — guide melody 2 on audio channel 2.
880    pub ach2_guide_melody_2: bool,
881    /// Byte 3 bit 3 — guide vocal 1 on audio channel 3.
882    pub ach3_guide_vocal_1: bool,
883    /// Byte 3 bit 2 — guide vocal 2 on audio channel 3.
884    pub ach3_guide_vocal_2: bool,
885    /// Byte 3 bit 1 — guide melody A on audio channel 3.
886    pub ach3_guide_melody_a: bool,
887    /// Byte 3 bit 0 — sound effect A on audio channel 3.
888    pub ach3_sound_effect_a: bool,
889    /// Byte 4 bit 3 — guide vocal 1 on audio channel 4.
890    pub ach4_guide_vocal_1: bool,
891    /// Byte 4 bit 2 — guide vocal 2 on audio channel 4.
892    pub ach4_guide_vocal_2: bool,
893    /// Byte 4 bit 1 — guide melody B on audio channel 4.
894    pub ach4_guide_melody_b: bool,
895    /// Byte 4 bit 0 — sound effect B on audio channel 4.
896    pub ach4_sound_effect_b: bool,
897}
898
899impl McExtensionEntry {
900    /// Parse one 8-byte multichannel-extension entry. (The spec
901    /// labels the entry's footprint as `8*24` bytes — actually 24
902    /// entries × 8 bytes each — but each individual entry is 8
903    /// bytes wide.)
904    pub fn parse(buf: &[u8; 8]) -> Self {
905        let b0 = buf[0];
906        let b1 = buf[1];
907        let b2 = buf[2];
908        let b3 = buf[3];
909        let b4 = buf[4];
910        Self {
911            raw: *buf,
912            ach0_guide_melody: (b0 & 0b0000_0001) != 0,
913            ach1_guide_melody: (b1 & 0b0000_0001) != 0,
914            ach2_guide_vocal_1: (b2 & 0b0000_1000) != 0,
915            ach2_guide_vocal_2: (b2 & 0b0000_0100) != 0,
916            ach2_guide_melody_1: (b2 & 0b0000_0010) != 0,
917            ach2_guide_melody_2: (b2 & 0b0000_0001) != 0,
918            ach3_guide_vocal_1: (b3 & 0b0000_1000) != 0,
919            ach3_guide_vocal_2: (b3 & 0b0000_0100) != 0,
920            ach3_guide_melody_a: (b3 & 0b0000_0010) != 0,
921            ach3_sound_effect_a: (b3 & 0b0000_0001) != 0,
922            ach4_guide_vocal_1: (b4 & 0b0000_1000) != 0,
923            ach4_guide_vocal_2: (b4 & 0b0000_0100) != 0,
924            ach4_guide_melody_b: (b4 & 0b0000_0010) != 0,
925            ach4_sound_effect_b: (b4 & 0b0000_0001) != 0,
926        }
927    }
928}
929
930// ------------------------------------------------------------------
931// VTSI_MAT — Video Title Set Information Management Table
932// ------------------------------------------------------------------
933
934/// VMGM / VTSM-side menu-attribute block — video format plus the
935/// audio + sub-picture stream lists for the menu VOBS.
936///
937/// `audio_streams` and `subpicture_streams` are populated to the
938/// declared `number_of_*_streams` count (≤ 8 for audio per the
939/// 8×8-byte attribute slot, ≤ 1 for sub-picture per the single
940/// 6-byte slot). Any tail attribute slots beyond the declared
941/// counts are ignored.
942#[derive(Debug, Clone, Default, PartialEq, Eq)]
943pub struct MenuAttributes {
944    pub video: Option<VideoAttributes>,
945    pub audio_streams: Vec<AudioAttributes>,
946    pub subpicture_streams: Vec<SubpictureAttributes>,
947}
948
949/// VTS_VOBS-side title-attribute block — video format plus the
950/// title audio (≤ 8) and sub-picture (≤ 32) stream lists, plus the
951/// karaoke multichannel-extension table (24 × 8-byte entries; one
952/// per audio stream slot, even for slots not populated).
953#[derive(Debug, Clone, Default, PartialEq, Eq)]
954pub struct TitleAttributes {
955    pub video: Option<VideoAttributes>,
956    pub audio_streams: Vec<AudioAttributes>,
957    pub subpicture_streams: Vec<SubpictureAttributes>,
958    /// Karaoke multichannel extension entries — populated when the
959    /// buffer is long enough to cover offset 0x0318..0x03D8 of
960    /// VTSI_MAT. Empty for non-karaoke titles and for buffers
961    /// shorter than 0x03D8.
962    pub multichannel_extension: Vec<McExtensionEntry>,
963}
964
965/// Parsed VTSI_MAT (the first 0x300 + audio/sub-picture extension
966/// bytes of a `VTS_xx_0.IFO` file).
967///
968/// Like [`VmgIfo`], sector-pointer fields stay raw — `0` denotes
969/// "absent".
970#[derive(Debug, Clone)]
971pub struct VtsiMat {
972    /// Last sector of this title set (last sector of `VTS_xx_0.BUP`).
973    pub last_sector_title_set: u32,
974    /// Last sector of this IFO file (`VTS_xx_0.IFO`).
975    pub last_sector_ifo: u32,
976    /// Version number — see [`VmgIfo::version`].
977    pub version: u16,
978    /// VTS category (0 = unspecified, 1 = Karaoke).
979    pub vts_category: u32,
980    /// Last byte of the VTSI_MAT region.
981    pub vtsi_mat_end: u32,
982    /// Start sector of the menu VOB (`0` if no menu).
983    pub menu_vob_sector: u32,
984    /// Start sector of the title VOBs.
985    pub title_vob_sector: u32,
986    /// Sector pointer to VTS_PTT_SRPT.
987    pub vts_ptt_srpt_sector: u32,
988    /// Sector pointer to VTS_PGCI.
989    pub vts_pgci_sector: u32,
990    /// Sector pointer to VTSM_PGCI_UT (menu PGCI).
991    pub vtsm_pgci_ut_sector: u32,
992    /// Sector pointer to VTS_TMAPTI (time map table).
993    pub vts_tmapti_sector: u32,
994    /// Sector pointer to VTSM_C_ADT (menu cell address table).
995    pub vtsm_c_adt_sector: u32,
996    /// Sector pointer to VTSM_VOBU_ADMAP (menu VOBU address map).
997    pub vtsm_vobu_admap_sector: u32,
998    /// Sector pointer to VTS_C_ADT (title-set cell address table).
999    pub vts_c_adt_sector: u32,
1000    /// Sector pointer to VTS_VOBU_ADMAP (title-set VOBU address map).
1001    pub vts_vobu_admap_sector: u32,
1002    /// VTSM (menu) stream attributes at 0x0100..0x015C. Empty
1003    /// when the buffer was too short to cover that region — the
1004    /// minimal buffer-length check is still `0x200` for backwards
1005    /// compatibility with the original sector-only parse.
1006    pub menu_attributes: MenuAttributes,
1007    /// VTS_VOBS (title-content) stream attributes at 0x0200..
1008    /// 0x03D8. Empty when the buffer was too short to cover that
1009    /// region.
1010    pub title_attributes: TitleAttributes,
1011}
1012
1013impl VtsiMat {
1014    /// Parse a `VTS_xx_0.IFO` byte buffer. Buffer must cover at least
1015    /// the VTSI_MAT region (the first 0x200 bytes).
1016    ///
1017    /// Audio / sub-picture / multichannel attribute extension blocks
1018    /// are decoded opportunistically — fields populated up to the
1019    /// last full block the buffer covers, the rest stays empty.
1020    pub fn parse(buf: &[u8]) -> Result<Self> {
1021        if buf.len() < 0x200 {
1022            return Err(Error::InvalidUdf("VTSI_MAT: buffer shorter than 0x200"));
1023        }
1024        if &buf[0..12] != VTS_MAGIC {
1025            return Err(Error::InvalidUdf("VTSI_MAT: bad magic"));
1026        }
1027        let menu_attributes = parse_menu_attribute_block(buf, 0x0100)?;
1028        let title_attributes = parse_title_attribute_block(buf)?;
1029        Ok(Self {
1030            last_sector_title_set: read_u32(buf, 0x000C)?,
1031            last_sector_ifo: read_u32(buf, 0x001C)?,
1032            version: read_u16(buf, 0x0020)?,
1033            vts_category: read_u32(buf, 0x0022)?,
1034            vtsi_mat_end: read_u32(buf, 0x0080)?,
1035            menu_vob_sector: read_u32(buf, 0x00C0)?,
1036            title_vob_sector: read_u32(buf, 0x00C4)?,
1037            vts_ptt_srpt_sector: read_u32(buf, 0x00C8)?,
1038            vts_pgci_sector: read_u32(buf, 0x00CC)?,
1039            vtsm_pgci_ut_sector: read_u32(buf, 0x00D0)?,
1040            vts_tmapti_sector: read_u32(buf, 0x00D4)?,
1041            vtsm_c_adt_sector: read_u32(buf, 0x00D8)?,
1042            vtsm_vobu_admap_sector: read_u32(buf, 0x00DC)?,
1043            vts_c_adt_sector: read_u32(buf, 0x00E0)?,
1044            vts_vobu_admap_sector: read_u32(buf, 0x00E4)?,
1045            menu_attributes,
1046            title_attributes,
1047        })
1048    }
1049}
1050
1051/// Decode a menu-attribute block (VMGM- or VTSM-side) that starts at
1052/// `block_off`. The block's footprint per mpucoder-ifo.html
1053/// 0x0100..0x015C is:
1054///
1055/// ```text
1056///   +0x00  u16  video attributes (2 bytes)
1057///   +0x02  u16  number of audio streams (≤ 8)
1058///   +0x04  8 × 8 bytes  audio attributes
1059///   +0x44  16 bytes     reserved
1060///   +0x54  u16  number of subpicture streams (0 or 1)
1061///   +0x56  6 bytes      subpicture attribute slot (slot 0)
1062///   +0x5C  ...           reserved tail
1063/// ```
1064fn parse_menu_attribute_block(buf: &[u8], block_off: usize) -> Result<MenuAttributes> {
1065    // Need at least the 2-byte video field + 2-byte audio count.
1066    if buf.len() < block_off + 0x04 {
1067        return Ok(MenuAttributes::default());
1068    }
1069    let video = read_video_attr(buf, block_off)?;
1070    let audio_count = read_u16(buf, block_off + 0x02)? as usize;
1071    let mut audio_streams = Vec::new();
1072    if buf.len() >= block_off + 0x04 + 8 * 8 {
1073        for i in 0..audio_count.min(8) {
1074            audio_streams.push(read_audio_attr(buf, block_off + 0x04 + i * 8)?);
1075        }
1076    }
1077    let subp_count_off = block_off + 0x54;
1078    let mut subpicture_streams = Vec::new();
1079    if buf.len() >= subp_count_off + 2 + 6 {
1080        let subp_count = read_u16(buf, subp_count_off)? as usize;
1081        if subp_count >= 1 {
1082            subpicture_streams.push(read_subp_attr(buf, subp_count_off + 2)?);
1083        }
1084    }
1085    Ok(MenuAttributes {
1086        video: Some(video),
1087        audio_streams,
1088        subpicture_streams,
1089    })
1090}
1091
1092/// Decode the VTS-VOBS title attribute block at fixed offset 0x0200.
1093/// The block's footprint is:
1094///
1095/// ```text
1096///   0x0200  u16  video attributes
1097///   0x0202  u16  number of audio streams (≤ 8)
1098///   0x0204  8 × 8 bytes  audio attributes
1099///   0x0244  16 bytes     reserved
1100///   0x0254  u16  number of subpicture streams (≤ 32)
1101///   0x0256  32 × 6 bytes  subpicture attributes
1102///   0x0316  2 bytes      reserved
1103///   0x0318  24 × 8 bytes  multichannel-extension entries (karaoke only)
1104///   0x03D8  end
1105/// ```
1106fn parse_title_attribute_block(buf: &[u8]) -> Result<TitleAttributes> {
1107    if buf.len() < 0x0204 {
1108        return Ok(TitleAttributes::default());
1109    }
1110    let video = read_video_attr(buf, 0x0200)?;
1111    let audio_count = read_u16(buf, 0x0202)? as usize;
1112    let mut audio_streams = Vec::new();
1113    if buf.len() >= 0x0204 + 8 * 8 {
1114        for i in 0..audio_count.min(8) {
1115            audio_streams.push(read_audio_attr(buf, 0x0204 + i * 8)?);
1116        }
1117    }
1118    let mut subpicture_streams = Vec::new();
1119    if buf.len() >= 0x0256 + 32 * 6 {
1120        let subp_count = read_u16(buf, 0x0254)? as usize;
1121        for i in 0..subp_count.min(32) {
1122            subpicture_streams.push(read_subp_attr(buf, 0x0256 + i * 6)?);
1123        }
1124    }
1125    // Multichannel extension — the spec slot is 24 × 8 = 192 bytes
1126    // starting at 0x0318 and ending at 0x03D8. Only decode the
1127    // entries that fit; non-karaoke titles leave the slot all-zero
1128    // (which Default-decodes cleanly, hence we still populate the
1129    // table — callers can inspect `vts_category` or the audio
1130    // `multichannel_extension_present` flag to know whether to
1131    // consume them).
1132    let mut multichannel_extension = Vec::new();
1133    if buf.len() >= 0x03D8 {
1134        for i in 0..24 {
1135            let off = 0x0318 + i * 8;
1136            let slice: [u8; 8] = buf[off..off + 8]
1137                .try_into()
1138                .map_err(|_| Error::InvalidUdf("VTSI_MAT: MC ext slice"))?;
1139            multichannel_extension.push(McExtensionEntry::parse(&slice));
1140        }
1141    }
1142    Ok(TitleAttributes {
1143        video: Some(video),
1144        audio_streams,
1145        subpicture_streams,
1146        multichannel_extension,
1147    })
1148}
1149
1150fn read_video_attr(buf: &[u8], off: usize) -> Result<VideoAttributes> {
1151    let slice = buf
1152        .get(off..off + 2)
1153        .ok_or(Error::InvalidUdf("ifo: video attr read past end"))?;
1154    Ok(VideoAttributes::parse(&[slice[0], slice[1]]))
1155}
1156
1157fn read_audio_attr(buf: &[u8], off: usize) -> Result<AudioAttributes> {
1158    let slice = buf
1159        .get(off..off + 8)
1160        .ok_or(Error::InvalidUdf("ifo: audio attr read past end"))?;
1161    let arr: [u8; 8] = slice
1162        .try_into()
1163        .map_err(|_| Error::InvalidUdf("ifo: audio attr slice"))?;
1164    Ok(AudioAttributes::parse(&arr))
1165}
1166
1167fn read_subp_attr(buf: &[u8], off: usize) -> Result<SubpictureAttributes> {
1168    let slice = buf
1169        .get(off..off + 6)
1170        .ok_or(Error::InvalidUdf("ifo: subp attr read past end"))?;
1171    let arr: [u8; 6] = slice
1172        .try_into()
1173        .map_err(|_| Error::InvalidUdf("ifo: subp attr slice"))?;
1174    Ok(SubpictureAttributes::parse(&arr))
1175}
1176
1177// ------------------------------------------------------------------
1178// VTS_PTT_SRPT — Part-of-Title Search Pointer Table (chapters)
1179// ------------------------------------------------------------------
1180
1181/// One PTT (chapter) — points to a `(PGCN, PGN)` pair within the VTS.
1182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1183pub struct Ptt {
1184    /// Program Chain number (1-based).
1185    pub pgcn: u16,
1186    /// Program number within the PGC (1-based).
1187    pub pgn: u16,
1188}
1189
1190/// One title in the PTT search pointer table — the list of chapters
1191/// for that title.
1192#[derive(Debug, Clone)]
1193pub struct PttTitle {
1194    pub chapters: Vec<Ptt>,
1195}
1196
1197/// Parsed VTS_PTT_SRPT — 8-byte header + per-title offset list +
1198/// per-title PTT entries.
1199#[derive(Debug, Clone)]
1200pub struct VtsPttSrpt {
1201    pub title_count: u16,
1202    pub end_address: u32,
1203    pub titles: Vec<PttTitle>,
1204}
1205
1206impl VtsPttSrpt {
1207    /// Parse a VTS_PTT_SRPT body.
1208    ///
1209    /// Layout per mpucoder-ifo_vts.html:
1210    ///
1211    /// ```text
1212    ///   0000: u16 number_of_titles (Nt)
1213    ///   0002: u16 reserved
1214    ///   0004: u32 end_address (last byte of last VTS_PTT)
1215    ///   0008: u32 offset_to_PTT[1]     ← VTS_PTTI[1]
1216    ///   000C: u32 offset_to_PTT[2]     ← VTS_PTTI[2]
1217    ///   ...
1218    ///   ...
1219    /// ```
1220    ///
1221    /// Each title's PTT region is a list of 4-byte `(PGCN, PGN)` pairs.
1222    /// The end of title i's region is bounded by either title i+1's
1223    /// offset (for i < Nt) or by `end_address + 1` (for i == Nt). We
1224    /// divide that span by 4 to recover the chapter count.
1225    pub fn parse(buf: &[u8]) -> Result<Self> {
1226        if buf.len() < 8 {
1227            return Err(Error::InvalidUdf(
1228                "VTS_PTT_SRPT: shorter than 8-byte header",
1229            ));
1230        }
1231        let title_count = read_u16(buf, 0)?;
1232        let end_address = read_u32(buf, 4)?;
1233        let nt = usize::from(title_count);
1234        let offsets_end = 8usize.saturating_add(nt * 4);
1235        if buf.len() < offsets_end {
1236            return Err(Error::InvalidUdf(
1237                "VTS_PTT_SRPT: offset list past end of buffer",
1238            ));
1239        }
1240        let mut offsets = Vec::with_capacity(nt);
1241        for i in 0..nt {
1242            offsets.push(read_u32(buf, 8 + i * 4)? as usize);
1243        }
1244        let mut titles = Vec::with_capacity(nt);
1245        for i in 0..nt {
1246            let start = offsets[i];
1247            // End of this title's PTT region: next title's offset, or
1248            // (end_address + 1) for the last title.
1249            let end_excl = if i + 1 < nt {
1250                offsets[i + 1]
1251            } else {
1252                (end_address as usize).saturating_add(1)
1253            };
1254            if end_excl < start {
1255                return Err(Error::InvalidUdf(
1256                    "VTS_PTT_SRPT: title offsets not monotonic",
1257                ));
1258            }
1259            let span = end_excl - start;
1260            if span % 4 != 0 {
1261                return Err(Error::InvalidUdf(
1262                    "VTS_PTT_SRPT: title span not a multiple of 4",
1263                ));
1264            }
1265            let n_ptt = span / 4;
1266            if buf.len() < start + n_ptt * 4 {
1267                return Err(Error::InvalidUdf(
1268                    "VTS_PTT_SRPT: title body past end of buffer",
1269                ));
1270            }
1271            let mut chapters = Vec::with_capacity(n_ptt);
1272            for j in 0..n_ptt {
1273                let off = start + j * 4;
1274                chapters.push(Ptt {
1275                    pgcn: read_u16(buf, off)?,
1276                    pgn: read_u16(buf, off + 2)?,
1277                });
1278            }
1279            titles.push(PttTitle { chapters });
1280        }
1281        Ok(Self {
1282            title_count,
1283            end_address,
1284            titles,
1285        })
1286    }
1287}
1288
1289// ------------------------------------------------------------------
1290// PGC — Program Chain (header + cells)
1291// ------------------------------------------------------------------
1292
1293/// Per-cell playback information (16 bytes per entry in C_PBI).
1294///
1295/// Field layout per mpucoder-pgc.html "cell playback information
1296/// table entry":
1297///
1298/// - byte 0: cell category bits (cell type, block type, seamless,
1299///   interleaved, STC discontinuity, seamless-angle).
1300/// - byte 1: restricted flag (`0x80` = trick-play disallowed).
1301/// - byte 2: cell still time.
1302/// - byte 3: cell command # (1..=128, 0 = no command).
1303/// - bytes 4..8: cell playback time (BCD).
1304/// - bytes 8..12: first VOBU start sector.
1305/// - bytes 12..16: first ILVU end sector.
1306/// - bytes 16..20: last VOBU start sector.
1307/// - bytes 20..24: last VOBU end sector.
1308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1309pub struct CellPlaybackInfo {
1310    pub category_byte0: u8,
1311    pub restricted: bool,
1312    pub still_time: u8,
1313    pub cell_command: u8,
1314    pub playback_time: PgcTime,
1315    pub first_vobu_start_sector: u32,
1316    pub first_ilvu_end_sector: u32,
1317    pub last_vobu_start_sector: u32,
1318    pub last_vobu_end_sector: u32,
1319}
1320
1321impl CellPlaybackInfo {
1322    fn parse(buf: &[u8]) -> Result<Self> {
1323        if buf.len() < 24 {
1324            return Err(Error::InvalidUdf("C_PBI entry shorter than 24 bytes"));
1325        }
1326        let category_byte0 = read_u8(buf, 0)?;
1327        let restricted = (read_u8(buf, 1)? & 0x80) != 0;
1328        let still_time = read_u8(buf, 2)?;
1329        let cell_command = read_u8(buf, 3)?;
1330        let mut t = [0u8; 4];
1331        t.copy_from_slice(&buf[4..8]);
1332        let playback_time = PgcTime::from_bytes(t);
1333        let first_vobu_start_sector = read_u32(buf, 8)?;
1334        let first_ilvu_end_sector = read_u32(buf, 12)?;
1335        let last_vobu_start_sector = read_u32(buf, 16)?;
1336        let last_vobu_end_sector = read_u32(buf, 20)?;
1337        Ok(Self {
1338            category_byte0,
1339            restricted,
1340            still_time,
1341            cell_command,
1342            playback_time,
1343            first_vobu_start_sector,
1344            first_ilvu_end_sector,
1345            last_vobu_start_sector,
1346            last_vobu_end_sector,
1347        })
1348    }
1349}
1350
1351/// Per-cell position information (4 bytes per entry in C_POS).
1352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1353pub struct CellPositionInfo {
1354    pub vob_id: u16,
1355    pub cell_id: u8,
1356}
1357
1358impl CellPositionInfo {
1359    fn parse(buf: &[u8]) -> Result<Self> {
1360        if buf.len() < 4 {
1361            return Err(Error::InvalidUdf("C_POS entry shorter than 4 bytes"));
1362        }
1363        let vob_id = read_u16(buf, 0)?;
1364        // byte 2 reserved
1365        let cell_id = read_u8(buf, 3)?;
1366        Ok(Self { vob_id, cell_id })
1367    }
1368}
1369
1370/// One entry of the PGC subpicture/highlight colour-LUT.
1371///
1372/// Per mpucoder-pgc.html the PGC header at offset `0x00A4` carries a
1373/// `16 × 4`-byte palette laid out as `(0, Y, Cr, Cb)`: byte 0 is a
1374/// reserved/zero pad, then the luma + the two chroma-difference
1375/// samples in 8-bit BT.601-range form. These sixteen entries are the
1376/// colour source a subpicture (SPU) display-control sequence indexes
1377/// into via its 4-bit colour codes (the SPU itself only stores the
1378/// 0..=15 palette index + a contrast/alpha nibble — see
1379/// mpucoder-spu.html), so a renderer needs this table to resolve a
1380/// subtitle/menu pixel to an actual YCrCb value.
1381#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1382pub struct PaletteEntry {
1383    /// Luma (Y) sample, 8-bit.
1384    pub y: u8,
1385    /// Cr (red chroma-difference) sample, 8-bit.
1386    pub cr: u8,
1387    /// Cb (blue chroma-difference) sample, 8-bit.
1388    pub cb: u8,
1389}
1390
1391impl PaletteEntry {
1392    /// Parse one 4-byte `(0, Y, Cr, Cb)` palette cell. The leading
1393    /// byte is reserved and ignored.
1394    fn parse(buf: &[u8]) -> Result<Self> {
1395        if buf.len() < 4 {
1396            return Err(Error::InvalidUdf("PGC palette entry shorter than 4 bytes"));
1397        }
1398        Ok(Self {
1399            y: buf[1],
1400            cr: buf[2],
1401            cb: buf[3],
1402        })
1403    }
1404}
1405
1406/// One 8-byte DVD-Video navigation command (VM instruction word).
1407///
1408/// Per mpucoder-pgc.html every command in a PGC command table is a
1409/// fixed 8-byte word. Decoding the opcode/operand semantics is
1410/// Phase 3c VM work (mpucoder-vmi.html); at the container layer we
1411/// surface the raw word so a downstream interpreter can execute it.
1412/// We expose the leading byte's top three bits as `command_type`
1413/// (the VMI command-group selector) for convenience without
1414/// committing to a full opcode model.
1415#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1416pub struct NavCommand {
1417    /// The eight raw command bytes, big-endian on the wire.
1418    pub bytes: [u8; 8],
1419}
1420
1421impl NavCommand {
1422    /// Wrap an 8-byte command word.
1423    fn parse(buf: &[u8]) -> Result<Self> {
1424        let slice = buf
1425            .get(0..8)
1426            .ok_or(Error::InvalidUdf("PGC command shorter than 8 bytes"))?;
1427        let mut bytes = [0u8; 8];
1428        bytes.copy_from_slice(slice);
1429        Ok(Self { bytes })
1430    }
1431
1432    /// VMI command-type selector — the top three bits of byte 0.
1433    ///
1434    /// This is the coarse command-group field per mpucoder-vmi.html;
1435    /// full opcode decode is delegated to the Phase 3c VM via
1436    /// [`Self::decode_instruction`]. Provided so callers can classify
1437    /// a word (e.g. distinguish a link/jump from a SetSystem/Compare)
1438    /// without a full interpreter.
1439    pub fn command_type(&self) -> u8 {
1440        self.bytes[0] >> 5
1441    }
1442
1443    /// Decode this raw command word into a typed
1444    /// [`crate::nav::NavInstruction`].
1445    ///
1446    /// Convenience wrapper around the Phase 3c-precursor disassembler
1447    /// in the `nav` module, which adds a `decode()` method to this
1448    /// same `NavCommand` type. Callers iterating a
1449    /// [`PgcCommandTable`] can stay inside the `ifo` namespace and
1450    /// reach the typed instruction tree via a single method call
1451    /// rather than importing the `nav` module's surface explicitly.
1452    #[inline]
1453    pub fn decode_instruction(&self) -> crate::nav::NavInstruction {
1454        self.decode()
1455    }
1456}
1457
1458/// PGC command table — pre, post, and cell command lists.
1459///
1460/// Per mpucoder-pgc.html "command table" the table opens with an
1461/// 8-byte header (`pre count`, `post count`, `cell count`, and an
1462/// `end address` relative to the table start), followed by the three
1463/// command lists back to back, each entry a fixed 8-byte
1464/// [`NavCommand`]. The total `pre + post + cell` count is `<= 128`.
1465///
1466/// *Pre* commands run before the PGC's first cell; *post* commands
1467/// run after the last cell finishes; *cell* commands are referenced
1468/// by the per-cell `cell_command` index in [`CellPlaybackInfo`]
1469/// (1-based; `0` = none). Executing the words is Phase 3c VM work —
1470/// here we only carve the raw 8-byte words out of the table.
1471#[derive(Debug, Clone, Default, PartialEq, Eq)]
1472pub struct PgcCommandTable {
1473    /// Commands executed when the PGC starts.
1474    pub pre: Vec<NavCommand>,
1475    /// Commands executed when the PGC ends.
1476    pub post: Vec<NavCommand>,
1477    /// Commands indexed by a cell's `cell_command` field (1-based).
1478    pub cell: Vec<NavCommand>,
1479    /// `end address` field — last byte offset of the table relative
1480    /// to its own start.
1481    pub end_address: u16,
1482}
1483
1484impl PgcCommandTable {
1485    /// Parse a command table. `buf` must start at the table's first
1486    /// byte (the `pre count` u16) and span at least through the last
1487    /// command word.
1488    fn parse(buf: &[u8]) -> Result<Self> {
1489        if buf.len() < 8 {
1490            return Err(Error::InvalidUdf("PGC command table shorter than header"));
1491        }
1492        let pre_count = read_u16(buf, 0)?;
1493        let post_count = read_u16(buf, 2)?;
1494        let cell_count = read_u16(buf, 4)?;
1495        let end_address = read_u16(buf, 6)?;
1496
1497        let total = usize::from(pre_count) + usize::from(post_count) + usize::from(cell_count);
1498        // Spec invariant: the three lists together hold <= 128 words.
1499        if total > 128 {
1500            return Err(Error::InvalidUdf("PGC command table claims > 128 commands"));
1501        }
1502
1503        let read_list = |start: usize, count: u16| -> Result<Vec<NavCommand>> {
1504            let mut out = Vec::with_capacity(usize::from(count));
1505            for i in 0..usize::from(count) {
1506                let off = start + i * 8;
1507                let word = buf
1508                    .get(off..off + 8)
1509                    .ok_or(Error::InvalidUdf("PGC command table list past end"))?;
1510                out.push(NavCommand::parse(word)?);
1511            }
1512            Ok(out)
1513        };
1514
1515        let pre_start = 8usize;
1516        let post_start = pre_start + usize::from(pre_count) * 8;
1517        let cell_start = post_start + usize::from(post_count) * 8;
1518
1519        let pre = read_list(pre_start, pre_count)?;
1520        let post = read_list(post_start, post_count)?;
1521        let cell = read_list(cell_start, cell_count)?;
1522
1523        Ok(Self {
1524            pre,
1525            post,
1526            cell,
1527            end_address,
1528        })
1529    }
1530
1531    /// Iterate the **pre** command list as typed
1532    /// [`crate::nav::NavInstruction`]s.
1533    ///
1534    /// Per `docs/container/dvd/application/mpucoder-pgc.html` the
1535    /// pre-command list runs once at PGC entry, before the first
1536    /// cell. Each 8-byte [`NavCommand`] word is fed through the
1537    /// `nav` disassembler so iterator consumers receive the typed
1538    /// instruction tree directly without a manual decode step.
1539    pub fn pre_instructions(&self) -> impl Iterator<Item = crate::nav::NavInstruction> + '_ {
1540        self.pre.iter().map(NavCommand::decode_instruction)
1541    }
1542
1543    /// Iterate the **post** command list as typed
1544    /// [`crate::nav::NavInstruction`]s.
1545    ///
1546    /// The post-command list runs once when the last cell of the
1547    /// PGC finishes, per `mpucoder-pgc.html`. Same shape as
1548    /// [`Self::pre_instructions`].
1549    pub fn post_instructions(&self) -> impl Iterator<Item = crate::nav::NavInstruction> + '_ {
1550        self.post.iter().map(NavCommand::decode_instruction)
1551    }
1552
1553    /// Iterate the **cell** command list as typed
1554    /// [`crate::nav::NavInstruction`]s.
1555    ///
1556    /// The cell command list is indexed by each
1557    /// [`CellPlaybackInfo`]'s 1-based `cell_command` field per
1558    /// `mpucoder-pgc.html`; this iterator walks it in storage order.
1559    /// Use [`Self::cell_instruction`] for the indexed lookup a
1560    /// cell actually performs.
1561    pub fn cell_instructions(&self) -> impl Iterator<Item = crate::nav::NavInstruction> + '_ {
1562        self.cell.iter().map(NavCommand::decode_instruction)
1563    }
1564
1565    /// Look up a cell command by its 1-based index — the encoding
1566    /// `CellPlaybackInfo::cell_command` carries on the wire — and
1567    /// return the typed [`crate::nav::NavInstruction`].
1568    ///
1569    /// Per `mpucoder-pgc.html` `cell_command = 0` means "no command
1570    /// associated with this cell"; the spec stores cell commands in
1571    /// a 1-based table. Passing `0` returns `None`; passing any
1572    /// out-of-range index also returns `None` rather than panicking,
1573    /// matching the malformed-disc-tolerance pattern used elsewhere
1574    /// in this crate's typed decoders.
1575    pub fn cell_instruction(&self, index_1based: u16) -> Option<crate::nav::NavInstruction> {
1576        if index_1based == 0 {
1577            return None;
1578        }
1579        let idx = usize::from(index_1based - 1);
1580        self.cell.get(idx).map(NavCommand::decode_instruction)
1581    }
1582}
1583
1584/// Parsed PGC header + cell tables.
1585///
1586/// Layout per mpucoder-pgc.html:
1587///
1588/// - 0x0000..0x00E4: PGC general information (PGC_GI) +
1589///   PGC_AST_CTL + PGC_SPST_CTL + PGC_PB_TIME + still time +
1590///   playback mode + palette (16 × 4-byte `(0, Y, Cr, Cb)` at
1591///   0x00A4).
1592/// - 0x00E4: u16 offset_to_commands (relative to PGC start). The
1593///   command table at that offset holds the pre/post/cell
1594///   navigation command lists ([`PgcCommandTable`]).
1595/// - 0x00E6: u16 offset_to_program_map.
1596/// - 0x00E8: u16 offset_to_cell_playback_information.
1597/// - 0x00EA: u16 offset_to_cell_position_information.
1598#[derive(Debug, Clone)]
1599pub struct Pgc {
1600    /// Number of programs in this PGC.
1601    pub number_of_programs: u8,
1602    /// Number of cells.
1603    pub number_of_cells: u8,
1604    /// PGC playback time (BCD).
1605    pub playback_time: PgcTime,
1606    /// Prohibited user-operation mask.
1607    pub prohibited_user_ops: u32,
1608    /// Next PGCN (`0` = none).
1609    pub next_pgcn: u16,
1610    /// Previous PGCN (`0` = none).
1611    pub prev_pgcn: u16,
1612    /// "Goup" (group-up) PGCN (`0` = none).
1613    pub goup_pgcn: u16,
1614    /// PGC still time (`255` = infinite).
1615    pub still_time: u8,
1616    /// Playback mode (0 = sequential; non-zero encodes
1617    /// random/shuffle + program count, see spec).
1618    pub playback_mode: u8,
1619    /// Subpicture/highlight colour-LUT — 16 `(Y, Cr, Cb)` entries
1620    /// from PGC offset `0x00A4` per mpucoder-pgc.html.
1621    pub palette: [PaletteEntry; 16],
1622    /// Offset within PGC to commands table (`0` = absent).
1623    pub offset_commands: u16,
1624    /// Offset within PGC to program map (`0` = absent).
1625    pub offset_program_map: u16,
1626    /// Offset within PGC to cell-playback-information table.
1627    pub offset_cell_playback: u16,
1628    /// Offset within PGC to cell-position-information table.
1629    pub offset_cell_position: u16,
1630    /// Per-program entry-cell numbers, length = `number_of_programs`.
1631    pub program_map: Vec<u8>,
1632    /// Per-cell playback info.
1633    pub cells: Vec<CellPlaybackInfo>,
1634    /// Per-cell position info (VOB id + Cell id pairs).
1635    pub cell_positions: Vec<CellPositionInfo>,
1636    /// Parsed pre/post/cell navigation command table. `None` when
1637    /// `offset_commands == 0` (no command table present).
1638    pub commands: Option<PgcCommandTable>,
1639}
1640
1641impl Pgc {
1642    /// Parse one PGC blob. `buf` must start at the PGC's first byte
1643    /// and span at least through the last table referenced by the
1644    /// offset fields.
1645    pub fn parse(buf: &[u8]) -> Result<Self> {
1646        if buf.len() < 0xEC {
1647            return Err(Error::InvalidUdf("PGC: buffer shorter than header"));
1648        }
1649        let number_of_programs = read_u8(buf, 0x0002)?;
1650        let number_of_cells = read_u8(buf, 0x0003)?;
1651        let mut t = [0u8; 4];
1652        t.copy_from_slice(&buf[0x0004..0x0008]);
1653        let playback_time = PgcTime::from_bytes(t);
1654        let prohibited_user_ops = read_u32(buf, 0x0008)?;
1655        let next_pgcn = read_u16(buf, 0x009C)?;
1656        let prev_pgcn = read_u16(buf, 0x009E)?;
1657        let goup_pgcn = read_u16(buf, 0x00A0)?;
1658        let still_time = read_u8(buf, 0x00A2)?;
1659        let playback_mode = read_u8(buf, 0x00A3)?;
1660
1661        // Palette (subpicture colour-LUT): 16 × 4-byte (0, Y, Cr, Cb)
1662        // entries at PGC offset 0x00A4. This is part of the fixed
1663        // header (which the 0xEC length check above already covers).
1664        let mut palette = [PaletteEntry::default(); 16];
1665        for (i, slot) in palette.iter_mut().enumerate() {
1666            let base = 0x00A4 + i * 4;
1667            *slot = PaletteEntry::parse(&buf[base..base + 4])?;
1668        }
1669
1670        let offset_commands = read_u16(buf, 0x00E4)?;
1671        let offset_program_map = read_u16(buf, 0x00E6)?;
1672        let offset_cell_playback = read_u16(buf, 0x00E8)?;
1673        let offset_cell_position = read_u16(buf, 0x00EA)?;
1674
1675        // Program map: number_of_programs × 1 byte. Padded to word
1676        // boundary with zero per spec, but we only need the first N.
1677        let mut program_map = Vec::with_capacity(usize::from(number_of_programs));
1678        if offset_program_map != 0 {
1679            let base = usize::from(offset_program_map);
1680            for i in 0..usize::from(number_of_programs) {
1681                program_map.push(read_u8(buf, base + i)?);
1682            }
1683        }
1684
1685        // Cell playback information table: number_of_cells × 24 bytes.
1686        let mut cells = Vec::with_capacity(usize::from(number_of_cells));
1687        if offset_cell_playback != 0 {
1688            let base = usize::from(offset_cell_playback);
1689            for i in 0..usize::from(number_of_cells) {
1690                let entry = &buf
1691                    .get(base + i * 24..base + (i + 1) * 24)
1692                    .ok_or(Error::InvalidUdf("PGC: C_PBI past end of buffer"))?;
1693                cells.push(CellPlaybackInfo::parse(entry)?);
1694            }
1695        }
1696
1697        // Cell position information table: number_of_cells × 4 bytes.
1698        let mut cell_positions = Vec::with_capacity(usize::from(number_of_cells));
1699        if offset_cell_position != 0 {
1700            let base = usize::from(offset_cell_position);
1701            for i in 0..usize::from(number_of_cells) {
1702                let entry = &buf
1703                    .get(base + i * 4..base + (i + 1) * 4)
1704                    .ok_or(Error::InvalidUdf("PGC: C_POS past end of buffer"))?;
1705                cell_positions.push(CellPositionInfo::parse(entry)?);
1706            }
1707        }
1708
1709        // Command table (pre/post/cell command lists). Absent when
1710        // offset_commands == 0 per mpucoder-pgc.html.
1711        let commands = if offset_commands != 0 {
1712            let base = usize::from(offset_commands);
1713            let tbl = buf
1714                .get(base..)
1715                .ok_or(Error::InvalidUdf("PGC: command table past end of buffer"))?;
1716            Some(PgcCommandTable::parse(tbl)?)
1717        } else {
1718            None
1719        };
1720
1721        Ok(Self {
1722            number_of_programs,
1723            number_of_cells,
1724            playback_time,
1725            prohibited_user_ops,
1726            next_pgcn,
1727            prev_pgcn,
1728            goup_pgcn,
1729            still_time,
1730            playback_mode,
1731            palette,
1732            offset_commands,
1733            offset_program_map,
1734            offset_cell_playback,
1735            offset_cell_position,
1736            program_map,
1737            cells,
1738            cell_positions,
1739            commands,
1740        })
1741    }
1742
1743    /// Typed view over [`Self::prohibited_user_ops`].
1744    ///
1745    /// The PGC-level UOP-prohibition mask follows the same 25-bit
1746    /// layout as the PCI / TT_SRPT levels; this accessor wraps the
1747    /// raw word so callers can use named [`UserOp`] variants
1748    /// instead of magic bit numbers. Per
1749    /// `docs/container/dvd/application/mpucoder-uops.html`, a set
1750    /// bit inhibits the associated control.
1751    #[inline]
1752    pub fn uop_mask(&self) -> crate::uops::UopMask {
1753        crate::uops::UopMask::from_bits(self.prohibited_user_ops)
1754    }
1755
1756    /// `true` when `op` is **not** prohibited at the PGC level.
1757    /// The full player-visible answer is still subject to the
1758    /// TT_SRPT and PCI-VOBU masks per the spec's three-level OR
1759    /// rule; use [`crate::uops::UopMask::merge_or`] to combine.
1760    #[inline]
1761    pub fn is_user_op_allowed(&self, op: crate::uops::UserOp) -> bool {
1762        self.uop_mask().is_allowed(op)
1763    }
1764}
1765
1766// ------------------------------------------------------------------
1767// PGCI — Program Chain Information (table of PGCs)
1768// ------------------------------------------------------------------
1769
1770/// One entry in the PGCI SRP — category byte + offset to the PGC body.
1771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1772pub struct PgciSrp {
1773    pub category: u32,
1774    /// Offset to the PGC body, relative to the PGCI start.
1775    pub offset: u32,
1776}
1777
1778/// Parsed PGCI (VTS_PGCI or VMGM_PGCI body).
1779#[derive(Debug, Clone)]
1780pub struct Pgci {
1781    pub number_of_pgcs: u16,
1782    pub end_address: u32,
1783    pub srp: Vec<PgciSrp>,
1784    pub pgcs: Vec<Pgc>,
1785}
1786
1787impl Pgci {
1788    /// Parse a PGCI body. `buf` must start at the first byte of the
1789    /// PGCI table and span at least through the last PGC.
1790    pub fn parse(buf: &[u8]) -> Result<Self> {
1791        if buf.len() < 8 {
1792            return Err(Error::InvalidUdf("PGCI: shorter than 8-byte header"));
1793        }
1794        let number_of_pgcs = read_u16(buf, 0)?;
1795        let end_address = read_u32(buf, 4)?;
1796        let n = usize::from(number_of_pgcs);
1797        let srp_end = 8usize.saturating_add(n * 8);
1798        if buf.len() < srp_end {
1799            return Err(Error::InvalidUdf("PGCI: SRP list past end of buffer"));
1800        }
1801        let mut srp = Vec::with_capacity(n);
1802        for i in 0..n {
1803            let base = 8 + i * 8;
1804            srp.push(PgciSrp {
1805                category: read_u32(buf, base)?,
1806                offset: read_u32(buf, base + 4)?,
1807            });
1808        }
1809        let mut pgcs = Vec::with_capacity(n);
1810        for entry in &srp {
1811            let off = entry.offset as usize;
1812            if off == 0 || off >= buf.len() {
1813                return Err(Error::InvalidUdf("PGCI: PGC offset out of range"));
1814            }
1815            let pgc_buf = &buf[off..];
1816            pgcs.push(Pgc::parse(pgc_buf)?);
1817        }
1818        Ok(Self {
1819            number_of_pgcs,
1820            end_address,
1821            srp,
1822            pgcs,
1823        })
1824    }
1825}
1826
1827// ------------------------------------------------------------------
1828// VMGM_PGCI_UT / VTSM_PGCI_UT — Menu PGCI Unit Table
1829// ------------------------------------------------------------------
1830//
1831// Per `docs/container/dvd/application/mpucoder-ifo_vmg.html` §VMGM_PGCI_UT
1832// and `mpucoder-ifo_vts.html` §VTSM_PGCI_UT. Two-level hierarchy:
1833//
1834//   VMGM_PGCI_UT / VTSM_PGCI_UT
1835//     ├── language-unit search-pointer list (one per ISO 639 language)
1836//     │     each entry: ISO639 code (2 B) + language-code ext (1 B)
1837//     │     + menu-existence-flag byte (1 B) + offset to LU (4 B)
1838//     └── Language Unit (PGCI_LU)
1839//           ├── PGC search-pointer list (one per menu PGC in this LU)
1840//           │     each entry: PGC category (4 B) + offset to PGC (4 B)
1841//           └── PGC bodies (parsed via `Pgc::parse`)
1842//
1843// The 8-byte unit-header at the LU and the unit-table top use the same
1844// `{ u16 count, u16 reserved, u32 end_address }` shape, then each list
1845// entry is 8 bytes.
1846
1847/// Menu existence-flag bits used in a VTSM language-unit entry's byte 3.
1848///
1849/// Per `mpucoder-ifo_vts.html` §VTSM_PGCI_UT — bit set ⇒ the
1850/// corresponding menu exists in that language unit. Bit `0x80` ("root")
1851/// is the only flag VMGM_PGCI_UT uses (treated as "title" on the VMG
1852/// side per `mpucoder-ifo_vmg.html`).
1853pub mod menu_existence {
1854    /// `0x80` — root menu exists (VTSM) / title menu exists (VMGM).
1855    pub const ROOT: u8 = 0x80;
1856    /// `0x40` — sub-picture menu exists (VTSM only).
1857    pub const SUBPICTURE: u8 = 0x40;
1858    /// `0x20` — audio menu exists (VTSM only).
1859    pub const AUDIO: u8 = 0x20;
1860    /// `0x10` — angle menu exists (VTSM only).
1861    pub const ANGLE: u8 = 0x10;
1862    /// `0x08` — PTT (chapter) menu exists (VTSM only).
1863    pub const PTT: u8 = 0x08;
1864}
1865
1866/// Menu-type enum decoded from a PGC category byte 0 (low nibble) per
1867/// `mpucoder-ifo_vts.html` §VTSM_PGCI_UT (PGC category breakdown).
1868///
1869/// Only meaningful when the entry-PGC flag (`category[0] >> 7`) is set;
1870/// for non-entry PGCs the menu-type field is unused per the spec.
1871#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1872pub enum MenuType {
1873    /// VMGM-side: title menu (`2`).
1874    Title,
1875    /// VTSM-side: root menu (`3`).
1876    Root,
1877    /// VTSM-side: sub-picture menu (`4`).
1878    Subpicture,
1879    /// VTSM-side: audio menu (`5`).
1880    Audio,
1881    /// VTSM-side: angle menu (`6`).
1882    Angle,
1883    /// VTSM-side: PTT (chapter) menu (`7`).
1884    Ptt,
1885    /// Any other value the spec doesn't define (reserved).
1886    Unknown(u8),
1887}
1888
1889impl MenuType {
1890    /// Decode the low nibble of PGC category byte 0.
1891    pub fn from_nibble(n: u8) -> Self {
1892        match n & 0x0F {
1893            2 => MenuType::Title,
1894            3 => MenuType::Root,
1895            4 => MenuType::Subpicture,
1896            5 => MenuType::Audio,
1897            6 => MenuType::Angle,
1898            7 => MenuType::Ptt,
1899            other => MenuType::Unknown(other),
1900        }
1901    }
1902}
1903
1904/// One PGC search-pointer inside a Language Unit (`PGCI_LU`).
1905///
1906/// `category` is the 32-bit PGC category dword; the high bit of byte 0
1907/// is the entry-PGC flag and the low nibble of byte 0 is the menu type
1908/// (only valid when the entry-PGC flag is set). Bytes 2..4 carry the
1909/// parental management mask. `offset` is the byte distance from the
1910/// start of the enclosing Language Unit to the PGC body.
1911#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1912pub struct PgciLuSrp {
1913    /// Raw 32-bit PGC category dword.
1914    pub category: u32,
1915    /// Byte offset to the PGC body, relative to this Language Unit's
1916    /// start.
1917    pub offset: u32,
1918}
1919
1920impl PgciLuSrp {
1921    /// `true` when the entry-PGC flag (byte 0 bit 7) is set.
1922    pub fn is_entry_pgc(&self) -> bool {
1923        (self.category >> 24) & 0x80 != 0
1924    }
1925
1926    /// Decode the menu-type nibble (only meaningful when
1927    /// [`Self::is_entry_pgc`] is `true`).
1928    pub fn menu_type(&self) -> MenuType {
1929        MenuType::from_nibble((self.category >> 24) as u8)
1930    }
1931
1932    /// Parental management mask (low 16 bits of the category dword).
1933    pub fn parental_mask(&self) -> u16 {
1934        (self.category & 0xFFFF) as u16
1935    }
1936}
1937
1938/// One Language Unit (`PGCI_LU`) — a PGC search-pointer list plus the
1939/// parsed PGC bodies it points at.
1940#[derive(Debug, Clone)]
1941pub struct PgciLu {
1942    /// Number of PGCs in this language unit.
1943    pub number_of_pgcs: u16,
1944    /// Last byte address of the last PGC body in this LU, relative to
1945    /// the LU's start.
1946    pub end_address: u32,
1947    /// PGC search pointers in declaration order.
1948    pub srp: Vec<PgciLuSrp>,
1949    /// Parsed PGC bodies in the same order as `srp`.
1950    pub pgcs: Vec<Pgc>,
1951}
1952
1953impl PgciLu {
1954    /// Parse a Language Unit body. `buf` must start at the LU's first
1955    /// byte and span at least through the last PGC body.
1956    pub fn parse(buf: &[u8]) -> Result<Self> {
1957        if buf.len() < 8 {
1958            return Err(Error::InvalidUdf("PGCI_LU: shorter than 8-byte header"));
1959        }
1960        let number_of_pgcs = read_u16(buf, 0)?;
1961        // bytes 2..4 reserved
1962        let end_address = read_u32(buf, 4)?;
1963        let n = usize::from(number_of_pgcs);
1964        let srp_end = 8usize
1965            .checked_add(
1966                n.checked_mul(8)
1967                    .ok_or(Error::InvalidUdf("PGCI_LU: SRP list × 8 overflow"))?,
1968            )
1969            .ok_or(Error::InvalidUdf("PGCI_LU: SRP list size overflow"))?;
1970        if buf.len() < srp_end {
1971            return Err(Error::InvalidUdf("PGCI_LU: SRP list past end of buffer"));
1972        }
1973        let mut srp = Vec::with_capacity(n);
1974        for i in 0..n {
1975            let base = 8 + i * 8;
1976            srp.push(PgciLuSrp {
1977                category: read_u32(buf, base)?,
1978                offset: read_u32(buf, base + 4)?,
1979            });
1980        }
1981        let mut pgcs = Vec::with_capacity(n);
1982        for entry in &srp {
1983            let off = entry.offset as usize;
1984            if off == 0 || off >= buf.len() {
1985                return Err(Error::InvalidUdf("PGCI_LU: PGC offset out of range"));
1986            }
1987            pgcs.push(Pgc::parse(&buf[off..])?);
1988        }
1989        Ok(Self {
1990            number_of_pgcs,
1991            end_address,
1992            srp,
1993            pgcs,
1994        })
1995    }
1996}
1997
1998/// One language-unit search-pointer in the menu PGCI Unit Table.
1999///
2000/// `language_code` is the raw 16-bit ISO 639 alpha-2 code (two ASCII
2001/// letters packed big-endian — e.g. `b"en"` = `0x656E`).
2002/// `language_code_ext` is the 1-byte language-code extension slot (the
2003/// spec keeps it reserved-zero; mirrors the SPRM-17 / SPRM-19 layout).
2004/// `menu_existence` is the byte 3 flag byte — see the
2005/// [`menu_existence`] constants.
2006#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2007pub struct PgciUtSrp {
2008    /// Big-endian-packed ISO 639 language code.
2009    pub language_code: u16,
2010    /// Language-code extension byte (reserved-zero by the spec).
2011    pub language_code_ext: u8,
2012    /// Menu existence flag byte — bitset of [`menu_existence`] bits.
2013    pub menu_existence: u8,
2014    /// Byte offset to the Language Unit body, relative to the start
2015    /// of `PGCI_UT`.
2016    pub offset: u32,
2017}
2018
2019impl PgciUtSrp {
2020    /// `true` when bit `0x80` (root / title menu) is set.
2021    pub fn has_root_menu(&self) -> bool {
2022        self.menu_existence & menu_existence::ROOT != 0
2023    }
2024
2025    /// `true` when bit `0x40` (sub-picture menu) is set. Only
2026    /// meaningful for VTSM_PGCI_UT.
2027    pub fn has_subpicture_menu(&self) -> bool {
2028        self.menu_existence & menu_existence::SUBPICTURE != 0
2029    }
2030
2031    /// `true` when bit `0x20` (audio menu) is set. Only meaningful for
2032    /// VTSM_PGCI_UT.
2033    pub fn has_audio_menu(&self) -> bool {
2034        self.menu_existence & menu_existence::AUDIO != 0
2035    }
2036
2037    /// `true` when bit `0x10` (angle menu) is set. Only meaningful for
2038    /// VTSM_PGCI_UT.
2039    pub fn has_angle_menu(&self) -> bool {
2040        self.menu_existence & menu_existence::ANGLE != 0
2041    }
2042
2043    /// `true` when bit `0x08` (PTT menu) is set. Only meaningful for
2044    /// VTSM_PGCI_UT.
2045    pub fn has_ptt_menu(&self) -> bool {
2046        self.menu_existence & menu_existence::PTT != 0
2047    }
2048}
2049
2050/// Parsed VMGM_PGCI_UT or VTSM_PGCI_UT body.
2051///
2052/// Two-level table: an outer search-pointer list keyed by ISO 639
2053/// language code, with each entry pointing at a Language Unit that
2054/// itself holds the menu PGCs for that language. The wire format is
2055/// identical between the VMG and VTS sides; the only difference is
2056/// the documented set of `menu_existence` / `MenuType` values
2057/// (the VMG side only authors a "title" menu type, while the VTS
2058/// side authors root / subpicture / audio / angle / PTT menus).
2059#[derive(Debug, Clone)]
2060pub struct PgciUt {
2061    /// Number of Language Units in this table.
2062    pub number_of_language_units: u16,
2063    /// Last byte address of the last PGC body in the last LU,
2064    /// relative to the start of `PGCI_UT`.
2065    pub end_address: u32,
2066    /// Outer search-pointer list — one entry per language unit.
2067    pub srp: Vec<PgciUtSrp>,
2068    /// Parsed Language Units in the same order as `srp`.
2069    pub language_units: Vec<PgciLu>,
2070}
2071
2072impl PgciUt {
2073    /// Parse a `VMGM_PGCI_UT` or `VTSM_PGCI_UT` body. `buf` must start
2074    /// at the first byte of the table and span at least through the
2075    /// last PGC body in the last LU.
2076    pub fn parse(buf: &[u8]) -> Result<Self> {
2077        if buf.len() < 8 {
2078            return Err(Error::InvalidUdf("PGCI_UT: shorter than 8-byte header"));
2079        }
2080        let number_of_language_units = read_u16(buf, 0)?;
2081        // bytes 2..4 reserved
2082        let end_address = read_u32(buf, 4)?;
2083        let n = usize::from(number_of_language_units);
2084        let srp_end = 8usize
2085            .checked_add(
2086                n.checked_mul(8)
2087                    .ok_or(Error::InvalidUdf("PGCI_UT: SRP list × 8 overflow"))?,
2088            )
2089            .ok_or(Error::InvalidUdf("PGCI_UT: SRP list size overflow"))?;
2090        if buf.len() < srp_end {
2091            return Err(Error::InvalidUdf("PGCI_UT: SRP list past end of buffer"));
2092        }
2093        let mut srp = Vec::with_capacity(n);
2094        for i in 0..n {
2095            let base = 8 + i * 8;
2096            srp.push(PgciUtSrp {
2097                language_code: read_u16(buf, base)?,
2098                language_code_ext: buf[base + 2],
2099                menu_existence: buf[base + 3],
2100                offset: read_u32(buf, base + 4)?,
2101            });
2102        }
2103        let mut language_units = Vec::with_capacity(n);
2104        for entry in &srp {
2105            let off = entry.offset as usize;
2106            if off == 0 || off >= buf.len() {
2107                return Err(Error::InvalidUdf("PGCI_UT: LU offset out of range"));
2108            }
2109            language_units.push(PgciLu::parse(&buf[off..])?);
2110        }
2111        Ok(Self {
2112            number_of_language_units,
2113            end_address,
2114            srp,
2115            language_units,
2116        })
2117    }
2118
2119    /// Look up the Language Unit for the given 16-bit ISO 639 language
2120    /// code (e.g. `b"en"` packed big-endian = `0x656E`).
2121    pub fn language_unit(&self, language_code: u16) -> Option<&PgciLu> {
2122        self.srp
2123            .iter()
2124            .position(|s| s.language_code == language_code)
2125            .and_then(|i| self.language_units.get(i))
2126    }
2127}
2128
2129// ------------------------------------------------------------------
2130// VTS_C_ADT — Cell Address Table
2131// ------------------------------------------------------------------
2132
2133/// One row of the cell address table.
2134///
2135/// Per stnsoft-vmindx.html / mpucoder-ifo.html `c_adt`: each entry is
2136/// 12 bytes — `(vob_id u16, cell_id u8, reserved u8, start_sector
2137/// u32, end_sector u32)`.
2138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2139pub struct CellAddrEntry {
2140    pub vob_id: u16,
2141    pub cell_id: u8,
2142    pub start_sector: u32,
2143    pub end_sector: u32,
2144}
2145
2146/// Parsed VTS_C_ADT body — also covers VMGM_C_ADT and VTSM_C_ADT
2147/// since they share the wire format.
2148#[derive(Debug, Clone)]
2149pub struct VtsCAdt {
2150    /// Number of distinct VOB IDs covered (NOT the entry count —
2151    /// per spec, multiple entries can share a VOB ID for the cells
2152    /// inside that VOB).
2153    pub number_of_vob_ids: u16,
2154    /// `end_address` field.
2155    pub end_address: u32,
2156    /// Parsed cell-address entries.
2157    pub entries: Vec<CellAddrEntry>,
2158}
2159
2160impl VtsCAdt {
2161    /// Parse a C_ADT body. Entry count is recovered from
2162    /// `(end_address - 7) / 12` — the spec stores it implicitly.
2163    pub fn parse(buf: &[u8]) -> Result<Self> {
2164        if buf.len() < 8 {
2165            return Err(Error::InvalidUdf("C_ADT: shorter than 8-byte header"));
2166        }
2167        let number_of_vob_ids = read_u16(buf, 0)?;
2168        let end_address = read_u32(buf, 4)?;
2169        // end_address = byte index of the last entry's last byte
2170        // relative to start of C_ADT. The entries start at byte 8.
2171        // Entry count = (end_address + 1 - 8) / 12.
2172        let body_bytes = (end_address as usize).saturating_add(1).saturating_sub(8);
2173        if body_bytes % 12 != 0 {
2174            return Err(Error::InvalidUdf(
2175                "C_ADT: end_address implies non-12-byte entry size",
2176            ));
2177        }
2178        let n = body_bytes / 12;
2179        let needed = 8 + n * 12;
2180        if buf.len() < needed {
2181            return Err(Error::InvalidUdf("C_ADT: buffer shorter than entry table"));
2182        }
2183        let mut entries = Vec::with_capacity(n);
2184        for i in 0..n {
2185            let base = 8 + i * 12;
2186            entries.push(CellAddrEntry {
2187                vob_id: read_u16(buf, base)?,
2188                cell_id: read_u8(buf, base + 2)?,
2189                // byte 3 reserved
2190                start_sector: read_u32(buf, base + 4)?,
2191                end_sector: read_u32(buf, base + 8)?,
2192            });
2193        }
2194        Ok(Self {
2195            number_of_vob_ids,
2196            end_address,
2197            entries,
2198        })
2199    }
2200
2201    /// Look up the disc-LBA range for a `(vob_id, cell_id)` pair.
2202    /// Sectors are relative to the start of the title's VOB chunks.
2203    pub fn lookup(&self, vob_id: u16, cell_id: u8) -> Option<(u32, u32)> {
2204        self.entries
2205            .iter()
2206            .find(|e| e.vob_id == vob_id && e.cell_id == cell_id)
2207            .map(|e| (e.start_sector, e.end_sector))
2208    }
2209}
2210
2211// ------------------------------------------------------------------
2212// VOBU_ADMAP — VOBU Address Map (absolute sector list)
2213// ------------------------------------------------------------------
2214
2215/// Parsed VOBU address map — covers `VMGM_VOBU_ADMAP`,
2216/// `VTSM_VOBU_ADMAP`, and `VTS_VOBU_ADMAP` (the three tables share
2217/// the same wire layout per `mpucoder-ifo.html`).
2218///
2219/// Layout (per the same source):
2220///
2221/// ```text
2222///   0x00: u32 end_address (last byte of last entry, relative to map start)
2223///   0x04: u32 starting sector within VOB of VOBU 1
2224///   0x08: u32 starting sector within VOB of VOBU 2
2225///   ...
2226/// ```
2227///
2228/// Entry count is implicit in `end_address`: each entry is exactly
2229/// four bytes, so `(end_address + 1 - 4) / 4` rounds down to the
2230/// number of VOBUs.
2231///
2232/// The 4-byte sector values are VOB-relative (i.e. relative to the
2233/// start of the first VOB the map covers — `VTS_xx_1.VOB` for
2234/// `VTS_VOBU_ADMAP`, the menu VOB for the menu variants); a player
2235/// adds the title-set VOB base LBA recorded in the corresponding
2236/// `VTSI_MAT::title_vob_sector` to recover the absolute disc LBA.
2237#[derive(Debug, Clone)]
2238pub struct VobuAdmap {
2239    /// `end_address` field — last byte of the last entry, relative
2240    /// to the start of the address map.
2241    pub end_address: u32,
2242    /// VOB-relative starting sectors, one per VOBU, in playback
2243    /// order. Index 0 = VOBU 1; index `entries.len() - 1` = the last
2244    /// VOBU declared by the map.
2245    pub entries: Vec<u32>,
2246}
2247
2248impl VobuAdmap {
2249    /// Parse a VOBU_ADMAP body. `buf` must start at the 4-byte
2250    /// `end_address` field and span at least through the last entry.
2251    pub fn parse(buf: &[u8]) -> Result<Self> {
2252        if buf.len() < 4 {
2253            return Err(Error::InvalidUdf("VOBU_ADMAP: shorter than 4-byte header"));
2254        }
2255        let end_address = read_u32(buf, 0)?;
2256        // end_address points at the last byte of the last entry,
2257        // relative to the table start. Entries begin at byte 4; each
2258        // entry is 4 bytes wide.
2259        let body_bytes = (end_address as usize).saturating_add(1).saturating_sub(4);
2260        if body_bytes % 4 != 0 {
2261            return Err(Error::InvalidUdf(
2262                "VOBU_ADMAP: end_address implies non-4-byte entry size",
2263            ));
2264        }
2265        let n = body_bytes / 4;
2266        let needed = 4 + n * 4;
2267        if buf.len() < needed {
2268            return Err(Error::InvalidUdf(
2269                "VOBU_ADMAP: buffer shorter than entry table",
2270            ));
2271        }
2272        let mut entries = Vec::with_capacity(n);
2273        for i in 0..n {
2274            entries.push(read_u32(buf, 4 + i * 4)?);
2275        }
2276        Ok(Self {
2277            end_address,
2278            entries,
2279        })
2280    }
2281
2282    /// Number of VOBUs covered by this map.
2283    #[inline]
2284    pub fn vobu_count(&self) -> usize {
2285        self.entries.len()
2286    }
2287
2288    /// VOB-relative starting sector of the 1-based VOBU number.
2289    /// Returns `None` for `vobu_number == 0` or any number past the
2290    /// last entry.
2291    pub fn vobu_start_sector(&self, vobu_number: u32) -> Option<u32> {
2292        if vobu_number == 0 {
2293            return None;
2294        }
2295        self.entries.get((vobu_number - 1) as usize).copied()
2296    }
2297
2298    /// Translate a VOB-relative sector into the 1-based VOBU number
2299    /// whose range covers it. Returns `None` when the sector falls
2300    /// before the map's first VOBU or the map is empty.
2301    ///
2302    /// Because consecutive entries delimit each VOBU's range
2303    /// (entry `i` starts VOBU `i + 1`; entry `i + 1` starts the next
2304    /// one), the lookup is a partition search: the matching VOBU is
2305    /// the highest-indexed entry whose value `<= sector`.
2306    pub fn vobu_containing(&self, sector: u32) -> Option<u32> {
2307        if self.entries.is_empty() {
2308            return None;
2309        }
2310        // Binary partition.
2311        let idx = self.entries.partition_point(|&v| v <= sector);
2312        if idx == 0 {
2313            None
2314        } else {
2315            Some(idx as u32)
2316        }
2317    }
2318}
2319
2320// ------------------------------------------------------------------
2321// VTS_TMAPTI — Time Map Table (one map per PGC)
2322// ------------------------------------------------------------------
2323
2324/// One time-map entry: VOB-relative sector of the VOBU at the
2325/// associated time stamp.
2326///
2327/// Bit 31 of the on-disc value is a discontinuity flag; the remaining
2328/// 31 bits carry the VOB-relative sector. Per `mpucoder-ifo_vts.html`
2329/// "VTS_TMAP" the discontinuity bit signals that the previous entry's
2330/// time stamp is **not** continuous with this one — typically because
2331/// the underlying VOBU sits across a cell boundary or an `STC`
2332/// reset.
2333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2334pub struct TmapEntry {
2335    /// VOB-relative sector of the VOBU at this time index.
2336    pub sector: u32,
2337    /// `true` when the source flagged a time discontinuity with the
2338    /// previous entry (bit 31 of the raw word).
2339    pub discontinuous: bool,
2340}
2341
2342impl TmapEntry {
2343    /// Bit 31 of the raw entry word: discontinuity-with-previous
2344    /// flag per `mpucoder-ifo_vts.html`.
2345    pub const DISCONTINUITY_BIT: u32 = 1 << 31;
2346
2347    /// Low-31-bit sector mask.
2348    pub const SECTOR_MASK: u32 = 0x7FFF_FFFF;
2349
2350    /// Decode one 4-byte entry word.
2351    fn from_raw(raw: u32) -> Self {
2352        Self {
2353            sector: raw & Self::SECTOR_MASK,
2354            discontinuous: (raw & Self::DISCONTINUITY_BIT) != 0,
2355        }
2356    }
2357}
2358
2359/// One per-PGC time map: time-unit length plus the per-step sector
2360/// list.
2361///
2362/// Layout (per `mpucoder-ifo_vts.html` "VTS_TMAP"):
2363///
2364/// ```text
2365///   0x00: u8  time_unit (seconds per step)
2366///   0x01: u8  reserved
2367///   0x02: u16 number_of_entries (`0` for empty map)
2368///   0x04: u32 entry[0]   ← VOB-relative VOBU sector
2369///   0x08: u32 entry[1]
2370///   ...
2371/// ```
2372///
2373/// An empty time map (number_of_entries = 0) is legal and is the
2374/// authoring convention for a PGC the disc decided not to time-index
2375/// (typically very short menus or warning still-frames).
2376#[derive(Debug, Clone, PartialEq, Eq)]
2377pub struct VtsTmap {
2378    /// Seconds covered by one entry step.
2379    pub time_unit: u8,
2380    /// Decoded entry list, one per step.
2381    pub entries: Vec<TmapEntry>,
2382}
2383
2384impl VtsTmap {
2385    /// Parse one VTS_TMAP. `buf` must start at the `time_unit` byte
2386    /// and span at least through the last entry.
2387    pub fn parse(buf: &[u8]) -> Result<Self> {
2388        if buf.len() < 4 {
2389            return Err(Error::InvalidUdf("VTS_TMAP: shorter than 4-byte header"));
2390        }
2391        let time_unit = read_u8(buf, 0)?;
2392        // byte 1 reserved
2393        let number_of_entries = read_u16(buf, 2)?;
2394        let n = usize::from(number_of_entries);
2395        let needed = 4usize.saturating_add(n * 4);
2396        if buf.len() < needed {
2397            return Err(Error::InvalidUdf(
2398                "VTS_TMAP: buffer shorter than entry table",
2399            ));
2400        }
2401        let mut entries = Vec::with_capacity(n);
2402        for i in 0..n {
2403            let raw = read_u32(buf, 4 + i * 4)?;
2404            entries.push(TmapEntry::from_raw(raw));
2405        }
2406        Ok(Self { time_unit, entries })
2407    }
2408
2409    /// Translate a playback time `seconds` (PGC-relative) into the
2410    /// VOB-relative starting sector of the VOBU whose time bracket
2411    /// contains it. Returns `None` for an empty map or when the
2412    /// requested time falls before the first step.
2413    ///
2414    /// Bracket assignment per the spec: entry `i` (1-based) covers
2415    /// `[(i - 1) * time_unit, i * time_unit)` seconds.
2416    pub fn sector_at(&self, seconds: u32) -> Option<u32> {
2417        if self.entries.is_empty() || self.time_unit == 0 {
2418            return None;
2419        }
2420        let step = u32::from(self.time_unit);
2421        let idx_zero_based = (seconds / step) as usize;
2422        let idx = idx_zero_based.min(self.entries.len() - 1);
2423        Some(self.entries[idx].sector)
2424    }
2425
2426    /// Total time covered by the map, in seconds. `time_unit *
2427    /// number_of_entries` — the upper edge of the last step's
2428    /// bracket.
2429    pub fn total_seconds(&self) -> u32 {
2430        u32::from(self.time_unit) * self.entries.len() as u32
2431    }
2432}
2433
2434/// Parsed VTS_TMAPTI — one [`VtsTmap`] per program chain.
2435///
2436/// Layout (per `mpucoder-ifo_vts.html`):
2437///
2438/// ```text
2439///   0x00: u16 number_of_program_chains (Npgc)
2440///   0x02: u16 reserved
2441///   0x04: u32 end_address (last byte of last VTS_TMAP)
2442///   0x08: u32 offset_to_VTS_TMAP[1]
2443///   0x0C: u32 offset_to_VTS_TMAP[2]
2444///   ...
2445/// ```
2446///
2447/// "Each PGC MUST have a time map, even if it is empty" — the spec
2448/// guarantees `maps.len() == number_of_program_chains`.
2449#[derive(Debug, Clone)]
2450pub struct VtsTmapti {
2451    /// Number of program chains covered (= maps.len()).
2452    pub number_of_pgcs: u16,
2453    /// `end_address` field.
2454    pub end_address: u32,
2455    /// Decoded time maps, one per program chain. Index `i` (0-based)
2456    /// is the map for `PGCN = i + 1`.
2457    pub maps: Vec<VtsTmap>,
2458}
2459
2460impl VtsTmapti {
2461    /// Parse a VTS_TMAPTI body. `buf` must start at byte 0 of the
2462    /// time-map table and span at least through the last `VTS_TMAP`.
2463    pub fn parse(buf: &[u8]) -> Result<Self> {
2464        if buf.len() < 8 {
2465            return Err(Error::InvalidUdf("VTS_TMAPTI: shorter than 8-byte header"));
2466        }
2467        let number_of_pgcs = read_u16(buf, 0)?;
2468        let end_address = read_u32(buf, 4)?;
2469        let n = usize::from(number_of_pgcs);
2470        let offsets_end = 8usize.saturating_add(n * 4);
2471        if buf.len() < offsets_end {
2472            return Err(Error::InvalidUdf(
2473                "VTS_TMAPTI: offset list past end of buffer",
2474            ));
2475        }
2476        let mut offsets = Vec::with_capacity(n);
2477        for i in 0..n {
2478            offsets.push(read_u32(buf, 8 + i * 4)? as usize);
2479        }
2480        let mut maps = Vec::with_capacity(n);
2481        for off in &offsets {
2482            let tmap_buf = buf.get(*off..).ok_or(Error::InvalidUdf(
2483                "VTS_TMAPTI: VTS_TMAP offset past end of buffer",
2484            ))?;
2485            maps.push(VtsTmap::parse(tmap_buf)?);
2486        }
2487        Ok(Self {
2488            number_of_pgcs,
2489            end_address,
2490            maps,
2491        })
2492    }
2493
2494    /// Look up the time map for the 1-based program-chain number.
2495    pub fn get(&self, pgcn: u16) -> Option<&VtsTmap> {
2496        if pgcn == 0 {
2497            return None;
2498        }
2499        self.maps.get((pgcn - 1) as usize)
2500    }
2501}
2502
2503// ------------------------------------------------------------------
2504// High-level chapter / title materialisation
2505// ------------------------------------------------------------------
2506
2507/// A chapter (Part-of-Title) at the API surface — pulls fields from
2508/// the PTT entry, the referenced PGC's cell list, and the C_ADT.
2509#[derive(Debug, Clone)]
2510pub struct DvdChapter {
2511    /// 1-based chapter number within the title.
2512    pub number: u16,
2513    /// Program-chain number this chapter lives in.
2514    pub pgcn: u16,
2515    /// Program number within that PGC.
2516    pub pgn: u16,
2517    /// First cell of this chapter (inclusive, 1-based).
2518    pub start_cell: u8,
2519    /// Last cell of this chapter (inclusive, 1-based). For all
2520    /// chapters but the last in a PGC this is the cell immediately
2521    /// before the next chapter's `start_cell`; for the last chapter
2522    /// it is the PGC's final cell.
2523    pub end_cell: u8,
2524    /// PGC-relative playback time for this chapter — the BCD field
2525    /// from the PGC header itself (chapters within a PGC don't carry
2526    /// their own playback time field, so we surface the PGC total).
2527    pub playback_time: PgcTime,
2528}
2529
2530/// A title at the API surface — pulls fields from TT_SRPT (for the
2531/// title-level header) and VTS_PTT_SRPT (for the chapter list).
2532#[derive(Debug, Clone)]
2533pub struct DvdTitle {
2534    /// 1-based VTS_TTN — title number within its VTS.
2535    pub number: u8,
2536    /// Number of camera angles (1..=9).
2537    pub angle_count: u8,
2538    /// Number of chapters (= `chapters.len()`).
2539    pub chapter_count: u16,
2540    /// Per-chapter detail.
2541    pub chapters: Vec<DvdChapter>,
2542}
2543
2544/// Parsed VTS — pulls VTSI_MAT, VTS_PTT_SRPT, VTS_PGCI, VTS_C_ADT,
2545/// VTS_VOBU_ADMAP, and VTS_TMAPTI into a single materialised view
2546/// that's convenient for chapter enumeration and time-based seek
2547/// without re-walking the byte buffer.
2548#[derive(Debug, Clone)]
2549pub struct VtsIfo {
2550    /// VTS number (1..=99).
2551    pub vts_number: u8,
2552    /// Number of titles in this VTS.
2553    pub title_count: u8,
2554    /// Per-title chapter list.
2555    pub titles: Vec<DvdTitle>,
2556    /// All program chains in the VTS.
2557    pub pgcs: Vec<Pgc>,
2558    /// Cell address table.
2559    pub cell_adt: VtsCAdt,
2560    /// Per-VOBU sector list for the title-set VOBs
2561    /// (`VTS_xx_1.VOB` … `VTS_xx_9.VOB`). `None` when the IFO's
2562    /// `vts_vobu_admap_sector` field is zero (rare; the spec lists
2563    /// `VTS_VOBU_ADMAP` as mandatory but some authoring tools
2564    /// elide it on title sets that hold only menu VOBs).
2565    pub vobu_admap: Option<VobuAdmap>,
2566    /// Per-PGC time map. `None` when the IFO's `vts_tmapti_sector`
2567    /// field is zero (the spec lists `VTS_TMAPTI` as optional —
2568    /// without it the title set is not time-seekable).
2569    pub time_map: Option<VtsTmapti>,
2570    /// Raw VTSI_MAT (kept around so callers can reach sector
2571    /// pointers for the bits we don't materialise — the
2572    /// VMGM/VTSM menu tables, the VOBU address maps on the menu
2573    /// side, etc.).
2574    pub mat: VtsiMat,
2575}
2576
2577impl VtsIfo {
2578    /// Build a `VtsIfo` from the full IFO byte buffer (the entire
2579    /// `VTS_xx_0.IFO`, which is `last_sector_ifo` + 1 sectors long).
2580    ///
2581    /// Sector pointers in `VTSI_MAT` are interpreted as offsets into
2582    /// `buf` after multiplication by [`DVD_SECTOR`].
2583    pub fn parse(buf: &[u8], vts_number: u8) -> Result<Self> {
2584        let mat = VtsiMat::parse(buf)?;
2585
2586        // VTS_PTT_SRPT
2587        let ptt_off = (mat.vts_ptt_srpt_sector as usize)
2588            .checked_mul(DVD_SECTOR)
2589            .ok_or(Error::InvalidUdf("VTSI: PTT sector overflow"))?;
2590        let ptt_buf = buf
2591            .get(ptt_off..)
2592            .ok_or(Error::InvalidUdf("VTSI: PTT sector past end"))?;
2593        let ptt_srpt = VtsPttSrpt::parse(ptt_buf)?;
2594
2595        // VTS_PGCI
2596        let pgci_off = (mat.vts_pgci_sector as usize)
2597            .checked_mul(DVD_SECTOR)
2598            .ok_or(Error::InvalidUdf("VTSI: PGCI sector overflow"))?;
2599        let pgci_buf = buf
2600            .get(pgci_off..)
2601            .ok_or(Error::InvalidUdf("VTSI: PGCI sector past end"))?;
2602        let pgci = Pgci::parse(pgci_buf)?;
2603
2604        // VTS_C_ADT
2605        let cadt_off = (mat.vts_c_adt_sector as usize)
2606            .checked_mul(DVD_SECTOR)
2607            .ok_or(Error::InvalidUdf("VTSI: C_ADT sector overflow"))?;
2608        let cadt_buf = buf
2609            .get(cadt_off..)
2610            .ok_or(Error::InvalidUdf("VTSI: C_ADT sector past end"))?;
2611        let cell_adt = VtsCAdt::parse(cadt_buf)?;
2612
2613        // VTS_VOBU_ADMAP (optional — sector 0 means absent).
2614        let vobu_admap = if mat.vts_vobu_admap_sector != 0 {
2615            let off = (mat.vts_vobu_admap_sector as usize)
2616                .checked_mul(DVD_SECTOR)
2617                .ok_or(Error::InvalidUdf("VTSI: VOBU_ADMAP sector overflow"))?;
2618            let body = buf
2619                .get(off..)
2620                .ok_or(Error::InvalidUdf("VTSI: VOBU_ADMAP sector past end"))?;
2621            Some(VobuAdmap::parse(body)?)
2622        } else {
2623            None
2624        };
2625
2626        // VTS_TMAPTI (optional — sector 0 means absent).
2627        let time_map = if mat.vts_tmapti_sector != 0 {
2628            let off = (mat.vts_tmapti_sector as usize)
2629                .checked_mul(DVD_SECTOR)
2630                .ok_or(Error::InvalidUdf("VTSI: TMAPTI sector overflow"))?;
2631            let body = buf
2632                .get(off..)
2633                .ok_or(Error::InvalidUdf("VTSI: TMAPTI sector past end"))?;
2634            Some(VtsTmapti::parse(body)?)
2635        } else {
2636            None
2637        };
2638
2639        // Materialise the chapter list. The PTT entry gives us
2640        // (PGCN, PGN). To recover (start_cell, end_cell) we look at
2641        // the referenced PGC's program_map — entry `pgn-1` is the
2642        // start cell, entry `pgn` (if it exists) is the next chapter's
2643        // start cell minus one. The last chapter runs through the
2644        // PGC's last cell.
2645        let title_count_u8 = u8::try_from(ptt_srpt.title_count.min(255))
2646            .map_err(|_| Error::InvalidUdf("VTSI: title count > 255"))?;
2647        let mut titles = Vec::with_capacity(usize::from(title_count_u8));
2648        for (i, ptt_title) in ptt_srpt.titles.iter().enumerate() {
2649            let title_number = (i as u8).saturating_add(1);
2650            let mut chapters = Vec::with_capacity(ptt_title.chapters.len());
2651            for (ch_i, ptt) in ptt_title.chapters.iter().enumerate() {
2652                let pgc = pgci
2653                    .pgcs
2654                    .get(usize::from(ptt.pgcn.saturating_sub(1)))
2655                    .ok_or(Error::InvalidUdf(
2656                        "VTSI: PTT references PGCN past end of PGCI",
2657                    ))?;
2658                let pgn_idx = usize::from(ptt.pgn.saturating_sub(1));
2659                let start_cell = *pgc
2660                    .program_map
2661                    .get(pgn_idx)
2662                    .ok_or(Error::InvalidUdf("VTSI: PTT PGN past program_map"))?;
2663                // Determine end_cell: next chapter in the same PGC
2664                // gives us its start_cell - 1; otherwise the PGC's
2665                // last cell.
2666                let next_in_same_pgc = ptt_title.chapters.get(ch_i + 1).and_then(|next_ptt| {
2667                    if next_ptt.pgcn == ptt.pgcn {
2668                        pgc.program_map
2669                            .get(usize::from(next_ptt.pgn.saturating_sub(1)))
2670                            .copied()
2671                            .map(|next_start| next_start.saturating_sub(1))
2672                    } else {
2673                        None
2674                    }
2675                });
2676                let end_cell = next_in_same_pgc.unwrap_or(pgc.number_of_cells);
2677                chapters.push(DvdChapter {
2678                    number: (ch_i as u16).saturating_add(1),
2679                    pgcn: ptt.pgcn,
2680                    pgn: ptt.pgn,
2681                    start_cell,
2682                    end_cell,
2683                    playback_time: pgc.playback_time,
2684                });
2685            }
2686            let chapter_count = chapters.len() as u16;
2687            titles.push(DvdTitle {
2688                number: title_number,
2689                angle_count: 1,
2690                chapter_count,
2691                chapters,
2692            });
2693        }
2694
2695        Ok(Self {
2696            vts_number,
2697            title_count: title_count_u8,
2698            titles,
2699            pgcs: pgci.pgcs,
2700            cell_adt,
2701            vobu_admap,
2702            time_map,
2703            mat,
2704        })
2705    }
2706
2707    /// VOB-relative starting sector of the VOBU that covers playback
2708    /// time `seconds` (PGC-relative) for the 1-based program-chain
2709    /// number `pgcn`.
2710    ///
2711    /// Convenience wrapper around [`VtsTmapti::get`] + [`VtsTmap::sector_at`].
2712    /// Returns `None` when this title set carries no time map, when
2713    /// the requested PGCN is past the table, when the map for that
2714    /// PGC is empty, or when its `time_unit` field is zero.
2715    ///
2716    /// To recover the disc-absolute LBA, add
2717    /// [`VtsiMat::title_vob_sector`] to the returned VOB-relative
2718    /// sector.
2719    pub fn vobu_sector_at_pgc_time(&self, pgcn: u16, seconds: u32) -> Option<u32> {
2720        self.time_map
2721            .as_ref()
2722            .and_then(|t| t.get(pgcn))
2723            .and_then(|m| m.sector_at(seconds))
2724    }
2725}
2726
2727// ------------------------------------------------------------------
2728// VMG_VTS_ATRT — per-VTS attribute table on the VMG side
2729// ------------------------------------------------------------------
2730
2731/// One entry of the `VMG_VTS_ATRT` table.
2732///
2733/// Per `mpucoder-ifo_vmg.html` each `VTS_ATRT` body is:
2734///   - offset `0..4`: end address (EA) of this entry, relative to the
2735///     entry's own start.
2736///   - offset `4..8`: `VTS_CAT` — a 32-bit category copy of `0x022..0x025`
2737///     of the corresponding VTS IFO (`0` = unspecified, `1` = Karaoke).
2738///   - offset `8..EA-7`: raw copy of the VTS attribute block starting
2739///     at offset `0x0100` of the VTS IFO (usually `0x300` bytes long;
2740///     equivalent to the buffer fed to [`parse_menu_attribute_block`] +
2741///     the title-side block that [`VtsiMat::parse`] already handles).
2742///
2743/// We keep the attribute blob as raw bytes so callers that want the
2744/// typed shape can feed it back through [`MenuAttributes`] /
2745/// [`TitleAttributes`] reuse without us re-decoding the same fields
2746/// twice (once here, once in the VTSI proper). The 1-based VTS index
2747/// is the entry's position in the table.
2748#[derive(Debug, Clone)]
2749pub struct VmgVtsAtrtEntry {
2750    /// 1-based VTS number this entry mirrors.
2751    pub vts_number: u8,
2752    /// `VTS_CAT` copy of the VTS IFO's `0x022..0x025` field.
2753    pub vts_category: u32,
2754    /// Raw VTS attribute block — a verbatim copy of bytes `0x0100..`
2755    /// of the corresponding `VTS_xx_0.IFO` file (length = entry-EA - 7).
2756    pub attributes_blob: Vec<u8>,
2757}
2758
2759/// Parsed `VMG_VTS_ATRT` — the per-VTS attribute copies stored on the
2760/// VMG side.
2761///
2762/// `VIDEO_TS.IFO` carries `VMG_VTS_ATRT` so a player can answer
2763/// per-VTS attribute queries (audio/subpicture stream counts, codec
2764/// IDs, language codes, …) without having to open every `VTS_xx_0.IFO`
2765/// individually. Each entry mirrors the attribute block found at the
2766/// start of the corresponding VTSI body.
2767#[derive(Debug, Clone)]
2768pub struct VmgVtsAtrt {
2769    /// Number of title sets referenced by this table.
2770    pub number_of_title_sets: u16,
2771    /// Last byte address of the last `VTS_ATRT` entry — relative to
2772    /// the start of `VMG_VTS_ATRT` itself.
2773    pub end_address: u32,
2774    /// One entry per title set, in `vts_number` order (1-based).
2775    pub entries: Vec<VmgVtsAtrtEntry>,
2776}
2777
2778impl VmgVtsAtrt {
2779    /// Parse the `VMG_VTS_ATRT` table from a buffer that starts at
2780    /// the table's first byte.
2781    pub fn parse(buf: &[u8]) -> Result<Self> {
2782        if buf.len() < 8 {
2783            return Err(Error::InvalidUdf("VMG_VTS_ATRT: header < 8 bytes"));
2784        }
2785        let number_of_title_sets = read_u16(buf, 0)?;
2786        // bytes 2..4 reserved
2787        let end_address = read_u32(buf, 4)?;
2788
2789        let count = usize::from(number_of_title_sets);
2790        let offset_table_len = count.checked_mul(4).ok_or(Error::InvalidUdf(
2791            "VMG_VTS_ATRT: title-set count × 4 overflow",
2792        ))?;
2793        let header_len = 8usize
2794            .checked_add(offset_table_len)
2795            .ok_or(Error::InvalidUdf("VMG_VTS_ATRT: header length overflow"))?;
2796        if buf.len() < header_len {
2797            return Err(Error::InvalidUdf("VMG_VTS_ATRT: offset table past end"));
2798        }
2799
2800        // Read the offset list first so we can derive the EA of each
2801        // entry (next-entry-offset minus one, or table EA for the last).
2802        let mut offsets = Vec::with_capacity(count);
2803        for i in 0..count {
2804            offsets.push(read_u32(buf, 8 + i * 4)?);
2805        }
2806
2807        let mut entries = Vec::with_capacity(count);
2808        for (i, &off) in offsets.iter().enumerate() {
2809            // Per-entry "end address" lives in the entry header at
2810            // offset 0 (relative to the entry start). Trust the header
2811            // EA, but bound-check against the next entry's offset
2812            // (or the table EA) so a malformed entry can't read past
2813            // the buffer / past the next entry.
2814            let entry_start = off as usize;
2815            let entry_hdr = buf
2816                .get(entry_start..entry_start + 8)
2817                .ok_or(Error::InvalidUdf("VMG_VTS_ATRT: entry header past buffer"))?;
2818            let entry_ea_rel =
2819                u32::from_be_bytes([entry_hdr[0], entry_hdr[1], entry_hdr[2], entry_hdr[3]])
2820                    as usize;
2821            let vts_category =
2822                u32::from_be_bytes([entry_hdr[4], entry_hdr[5], entry_hdr[6], entry_hdr[7]]);
2823            // The entry's "end_address" is inclusive of the last byte
2824            // of the entry (zero-based, relative to the entry start);
2825            // total entry length is therefore `entry_ea_rel + 1`. The
2826            // attribute blob runs from offset 8 through end-of-entry.
2827            let entry_total = entry_ea_rel
2828                .checked_add(1)
2829                .ok_or(Error::InvalidUdf("VMG_VTS_ATRT: entry EA overflow"))?;
2830            if entry_total < 8 {
2831                return Err(Error::InvalidUdf(
2832                    "VMG_VTS_ATRT: entry length shorter than 8-byte header",
2833                ));
2834            }
2835            // Bound against the next entry's offset (if any) so an
2836            // overlong EA can't pull bytes out of the following entry.
2837            let next_start = offsets
2838                .get(i + 1)
2839                .map(|&n| n as usize)
2840                .unwrap_or((end_address as usize).saturating_add(1));
2841            if entry_start.saturating_add(entry_total) > next_start {
2842                return Err(Error::InvalidUdf(
2843                    "VMG_VTS_ATRT: entry EA overlaps next entry",
2844                ));
2845            }
2846            let blob_end = entry_start
2847                .checked_add(entry_total)
2848                .ok_or(Error::InvalidUdf("VMG_VTS_ATRT: entry end overflow"))?;
2849            let blob = buf
2850                .get(entry_start + 8..blob_end)
2851                .ok_or(Error::InvalidUdf("VMG_VTS_ATRT: blob past buffer"))?
2852                .to_vec();
2853            entries.push(VmgVtsAtrtEntry {
2854                vts_number: (i as u8).saturating_add(1),
2855                vts_category,
2856                attributes_blob: blob,
2857            });
2858        }
2859
2860        Ok(Self {
2861            number_of_title_sets,
2862            end_address,
2863            entries,
2864        })
2865    }
2866
2867    /// Return the entry for the 1-based `vts_number`, or `None` when
2868    /// the index is past the table.
2869    pub fn entry(&self, vts_number: u8) -> Option<&VmgVtsAtrtEntry> {
2870        if vts_number == 0 {
2871            return None;
2872        }
2873        self.entries.get(usize::from(vts_number - 1))
2874    }
2875}
2876
2877// ------------------------------------------------------------------
2878// VMG_PTL_MAIT — parental management table
2879// ------------------------------------------------------------------
2880
2881/// One country-keyed sub-table of `VMG_PTL_MAIT`.
2882///
2883/// Each country gets one `PTL_MAIT` body — a flat array of
2884/// `Nts + 1` 16-bit masks per parental level. The spec describes
2885/// the body as 8 stacked level-blocks (level 8 first at body offset
2886/// 0, then level 7, …, level 1 at body offset `14 × (Nts + 1)`).
2887///
2888/// `masks[level_minus_one][title_set_index]` is the 16-bit mask for
2889/// the title set (where `title_set_index = 0` is the VMG itself and
2890/// `1..=nts` are the title sets in `1..=nts` order).
2891#[derive(Debug, Clone)]
2892pub struct PtlMait {
2893    /// 16-bit country code (BCD-packed ISO 3166 per `mpucoder-sprm.html`).
2894    pub country_code: u16,
2895    /// Eight per-level mask arrays. `masks[0]` = level 1, …,
2896    /// `masks[7]` = level 8 (level order surfaced ascending — easier
2897    /// to index by `parental_level - 1` than to invert the spec's
2898    /// descending storage order at every call site).
2899    ///
2900    /// Each inner `Vec` has `nts + 1` entries: index `0` = VMG mask,
2901    /// `1..=nts` = title-set 1..=nts mask. A set bit at position `i`
2902    /// in `masks[L-1][T]` means title-set `T` is blocked from level `L`
2903    /// in this country.
2904    pub masks: [Vec<u16>; 8],
2905}
2906
2907impl PtlMait {
2908    /// Look up the mask for `parental_level` (1..=8) and
2909    /// `title_set` (0 = VMG, 1..=nts = title set).
2910    pub fn mask(&self, parental_level: u8, title_set: u8) -> Option<u16> {
2911        if !(1..=8).contains(&parental_level) {
2912            return None;
2913        }
2914        let level_idx = usize::from(parental_level - 1);
2915        self.masks[level_idx].get(usize::from(title_set)).copied()
2916    }
2917}
2918
2919/// Parsed `VMG_PTL_MAIT` (parental management country/level table).
2920///
2921/// `mpucoder-ifo_vmg.html` documents one country-keyed entry per
2922/// supported region — each entry pointing at a sub-table of 8
2923/// parental-level mask arrays of length `nts + 1`. Strict literal
2924/// reading of the entry layout (8 cells of 16 bits each):
2925/// `country_code (u16) | reserved (u16) | ptl_mait_offset (u16) |
2926/// reserved (u16)`. The 16-bit offset bounds the per-country
2927/// sub-table to within the first 64 KB of `VMG_PTL_MAIT`, which is
2928/// the natural ceiling on DVD-Video discs (99 title sets × 16 bytes
2929/// per level × 8 levels = 12.7 KB per country, with up to ~100
2930/// countries fitting under the bound).
2931#[derive(Debug, Clone)]
2932pub struct VmgPtlMait {
2933    /// Number of countries (each gets one sub-table).
2934    pub number_of_countries: u16,
2935    /// Number of title sets (the body sub-tables carry `Nts + 1`
2936    /// masks per level — index 0 is the VMG-side mask).
2937    pub number_of_title_sets: u16,
2938    /// Last byte address of the last `PTL_MAIT` body, relative to
2939    /// the start of `VMG_PTL_MAIT`.
2940    pub end_address: u32,
2941    /// One sub-table per country.
2942    pub entries: Vec<PtlMait>,
2943}
2944
2945impl VmgPtlMait {
2946    /// Parse `VMG_PTL_MAIT` from a buffer that starts at the
2947    /// table's first byte.
2948    pub fn parse(buf: &[u8]) -> Result<Self> {
2949        if buf.len() < 8 {
2950            return Err(Error::InvalidUdf("VMG_PTL_MAIT: header < 8 bytes"));
2951        }
2952        let number_of_countries = read_u16(buf, 0)?;
2953        let number_of_title_sets = read_u16(buf, 2)?;
2954        let end_address = read_u32(buf, 4)?;
2955
2956        let nts = usize::from(number_of_title_sets);
2957        let masks_per_level = nts
2958            .checked_add(1)
2959            .ok_or(Error::InvalidUdf("VMG_PTL_MAIT: nts + 1 overflow"))?;
2960        let level_block_bytes = masks_per_level
2961            .checked_mul(2)
2962            .ok_or(Error::InvalidUdf("VMG_PTL_MAIT: level block size overflow"))?;
2963        let body_bytes = level_block_bytes
2964            .checked_mul(8)
2965            .ok_or(Error::InvalidUdf("VMG_PTL_MAIT: body size overflow"))?;
2966
2967        let count = usize::from(number_of_countries);
2968        // Country entries are 8 bytes each starting at offset 8.
2969        let header_len = 8usize
2970            .checked_add(
2971                count
2972                    .checked_mul(8)
2973                    .ok_or(Error::InvalidUdf("VMG_PTL_MAIT: country list × 8 overflow"))?,
2974            )
2975            .ok_or(Error::InvalidUdf("VMG_PTL_MAIT: header length overflow"))?;
2976        if buf.len() < header_len {
2977            return Err(Error::InvalidUdf("VMG_PTL_MAIT: country list past end"));
2978        }
2979
2980        let mut entries = Vec::with_capacity(count);
2981        for i in 0..count {
2982            let entry_base = 8 + i * 8;
2983            let country_code = read_u16(buf, entry_base)?;
2984            // bytes 2..4 reserved
2985            let body_offset = usize::from(read_u16(buf, entry_base + 4)?);
2986            // bytes 6..8 reserved
2987            let body_end = body_offset
2988                .checked_add(body_bytes)
2989                .ok_or(Error::InvalidUdf("VMG_PTL_MAIT: country body end overflow"))?;
2990            if body_end > buf.len() {
2991                return Err(Error::InvalidUdf(
2992                    "VMG_PTL_MAIT: country body past buffer end",
2993                ));
2994            }
2995
2996            // The body stores level 8 first at body_offset, then level 7
2997            // at body_offset + level_block_bytes, … level 1 at
2998            // body_offset + 7 × level_block_bytes. Surface them in
2999            // ascending order so `masks[0]` = level 1.
3000            let mut masks: [Vec<u16>; 8] = Default::default();
3001            for level_storage_idx in 0..8 {
3002                // Storage idx 0 = level 8; storage idx 7 = level 1.
3003                let level_value = 8 - level_storage_idx; // 8..=1
3004                let block_start = body_offset + level_storage_idx * level_block_bytes;
3005                let mut row = Vec::with_capacity(masks_per_level);
3006                for slot in 0..masks_per_level {
3007                    row.push(read_u16(buf, block_start + slot * 2)?);
3008                }
3009                masks[level_value - 1] = row;
3010            }
3011
3012            entries.push(PtlMait {
3013                country_code,
3014                masks,
3015            });
3016        }
3017
3018        Ok(Self {
3019            number_of_countries,
3020            number_of_title_sets,
3021            end_address,
3022            entries,
3023        })
3024    }
3025
3026    /// Look up the country-keyed sub-table for the given 16-bit
3027    /// country code.
3028    pub fn country(&self, country_code: u16) -> Option<&PtlMait> {
3029        self.entries.iter().find(|e| e.country_code == country_code)
3030    }
3031}
3032
3033// ------------------------------------------------------------------
3034// Tests
3035// ------------------------------------------------------------------
3036
3037#[cfg(test)]
3038mod tests {
3039    use super::*;
3040
3041    // -------------------------------------------------------------
3042    // PgcTime decode
3043    // -------------------------------------------------------------
3044
3045    #[test]
3046    fn pgc_time_decode_ntsc_30() {
3047        // 01:23:45.20 @ 30 fps → bytes 0x01 0x23 0x45 (0b11_10_0000 = 0xE0).
3048        // Frame field: bits 7+6 = 11 (30 fps), bits 5+4 = 0b10 = 2 in BCD
3049        // hi-nibble (frame tens), bits 3+0 = 0b0000 = 0 in BCD lo-nibble.
3050        // So frames = 2 * 10 + 0 = 20.
3051        let t = PgcTime::from_bytes([0x01, 0x23, 0x45, 0xE0]);
3052        assert_eq!(t.hours, 1);
3053        assert_eq!(t.minutes, 23);
3054        assert_eq!(t.seconds, 45);
3055        assert_eq!(t.frames, 20);
3056        assert_eq!(t.frame_rate, FrameRate::Ntsc30);
3057        assert_eq!(t.total_seconds(), 3600 + 23 * 60 + 45);
3058    }
3059
3060    #[test]
3061    fn pgc_time_decode_pal_25() {
3062        // 00:00:01.00 @ 25 fps → bytes 0x00 0x00 0x01 (0b01_00_0000 = 0x40).
3063        let t = PgcTime::from_bytes([0x00, 0x00, 0x01, 0x40]);
3064        assert_eq!(t.frame_rate, FrameRate::Pal25);
3065        assert_eq!(t.frames, 0);
3066        assert_eq!(t.total_seconds(), 1);
3067    }
3068
3069    // -------------------------------------------------------------
3070    // PgcTime::to_nanoseconds — exposes the per-rate fractional-frame
3071    // conversion that mkv_writer previously held privately.
3072    // -------------------------------------------------------------
3073
3074    #[test]
3075    fn pgc_time_to_ns_ntsc_30_integer_seconds() {
3076        // 00:00:01.00 @ 30 fps → exactly 1 second, no frame fraction.
3077        let t = PgcTime::from_bytes([0x00, 0x00, 0x01, 0xC0]);
3078        assert_eq!(t.frame_rate, FrameRate::Ntsc30);
3079        assert_eq!(t.to_nanoseconds(), 1_000_000_000);
3080    }
3081
3082    #[test]
3083    fn pgc_time_to_ns_ntsc_30_half_second() {
3084        // 00:00:01.15 @ 30 fps → 1 s + 15/30 s = 1.5 s exact.
3085        // Frame byte: rate=11, frames-tens=01 (bits 5+4), frames-units=0101
3086        //   = 0b11_01_0101 = 0xD5. BCD frames hi-nibble = 1, lo-nibble = 5
3087        //   → 15 frames.
3088        let t = PgcTime::from_bytes([0x00, 0x00, 0x01, 0xD5]);
3089        assert_eq!(t.frame_rate, FrameRate::Ntsc30);
3090        assert_eq!(t.frames, 15);
3091        assert_eq!(t.to_nanoseconds(), 1_500_000_000);
3092    }
3093
3094    #[test]
3095    fn pgc_time_to_ns_pal_25_frame_period() {
3096        // 00:00:00.01 @ 25 fps → 1 frame × 40_000_000 ns/frame = 40 ms.
3097        // Frame byte: rate=01, frames=01 → 0b01_00_0001 = 0x41.
3098        let t = PgcTime::from_bytes([0x00, 0x00, 0x00, 0x41]);
3099        assert_eq!(t.frame_rate, FrameRate::Pal25);
3100        assert_eq!(t.frames, 1);
3101        assert_eq!(t.to_nanoseconds(), 40_000_000);
3102    }
3103
3104    #[test]
3105    fn pgc_time_to_ns_illegal_rate_drops_frames() {
3106        // 00:00:02.07 with rate bits = 00 (illegal). Whole-seconds
3107        // portion survives; the 7-frame fraction is dropped because the
3108        // spec defines no rate to scale it by.
3109        let t = PgcTime::from_bytes([0x00, 0x00, 0x02, 0x07]);
3110        assert_eq!(t.frame_rate, FrameRate::Illegal);
3111        assert_eq!(t.frames, 7);
3112        assert_eq!(t.to_nanoseconds(), 2_000_000_000);
3113    }
3114
3115    // -------------------------------------------------------------
3116    // VMGI MAT parse
3117    // -------------------------------------------------------------
3118
3119    fn build_vmg_mat() -> Vec<u8> {
3120        let mut b = vec![0u8; 0x200];
3121        b[0..12].copy_from_slice(VMG_MAGIC);
3122        // 0x000C: last sector of VMG set = 1000
3123        b[0x000C..0x0010].copy_from_slice(&1000u32.to_be_bytes());
3124        // 0x001C: last sector of IFO = 4
3125        b[0x001C..0x0020].copy_from_slice(&4u32.to_be_bytes());
3126        // 0x0020: version 0x0011 (major 1, minor 1)
3127        b[0x0020..0x0022].copy_from_slice(&0x0011u16.to_be_bytes());
3128        // 0x0022: VMG category (region mask byte 1 = 0xFF "no region")
3129        b[0x0022..0x0026].copy_from_slice(&0x00FF_0000u32.to_be_bytes());
3130        // 0x0026: number of volumes = 1
3131        b[0x0026..0x0028].copy_from_slice(&1u16.to_be_bytes());
3132        // 0x0028: volume number = 1
3133        b[0x0028..0x002A].copy_from_slice(&1u16.to_be_bytes());
3134        // 0x002A: side ID = 0
3135        b[0x002A] = 0;
3136        // 0x003E: number of title sets = 2
3137        b[0x003E..0x0040].copy_from_slice(&2u16.to_be_bytes());
3138        // 0x0040: provider ID "OXIDEAV-TEST"
3139        let pid = b"OXIDEAV-TEST";
3140        b[0x0040..0x0040 + pid.len()].copy_from_slice(pid);
3141        // 0x0080: VMGI_MAT end
3142        b[0x0080..0x0084].copy_from_slice(&0x01FFu32.to_be_bytes());
3143        // 0x0084: FP_PGC start address = 0
3144        b[0x0084..0x0088].copy_from_slice(&0u32.to_be_bytes());
3145        // 0x00C0: menu VOB sector = 0 (no menu)
3146        // 0x00C4: TT_SRPT sector = 1
3147        b[0x00C4..0x00C8].copy_from_slice(&1u32.to_be_bytes());
3148        // 0x00C8: VMGM_PGCI_UT sector = 0
3149        // 0x00CC: VMG_PTL_MAIT sector = 0
3150        // 0x00D0: VMG_VTS_ATRT sector = 2
3151        b[0x00D0..0x00D4].copy_from_slice(&2u32.to_be_bytes());
3152        // 0x00D4: TXTDT_MG sector = 0
3153        // 0x00D8: VMGM_C_ADT sector = 0
3154        // 0x00DC: VMGM_VOBU_ADMAP sector = 0
3155        b
3156    }
3157
3158    #[test]
3159    fn vmgi_mat_parse_roundtrip() {
3160        let buf = build_vmg_mat();
3161        let vmg = VmgIfo::parse(&buf).unwrap();
3162        assert_eq!(vmg.last_sector_vmg_set, 1000);
3163        assert_eq!(vmg.last_sector_ifo, 4);
3164        assert_eq!(vmg.version, 0x0011);
3165        assert_eq!(vmg.number_of_volumes, 1);
3166        assert_eq!(vmg.volume_number, 1);
3167        assert_eq!(vmg.side_id, 0);
3168        assert_eq!(vmg.number_of_title_sets, 2);
3169        assert_eq!(vmg.provider_id, "OXIDEAV-TEST");
3170        assert_eq!(vmg.tt_srpt_sector, 1);
3171        assert_eq!(vmg.vts_atrt_sector, 2);
3172        assert_eq!(vmg.menu_vob_sector, 0);
3173    }
3174
3175    #[test]
3176    fn vmgi_mat_rejects_bad_magic() {
3177        let mut buf = build_vmg_mat();
3178        buf[0..12].copy_from_slice(b"DVDVIDEO-BAD");
3179        let err = VmgIfo::parse(&buf).unwrap_err();
3180        match err {
3181            Error::InvalidUdf(_) => {}
3182            other => panic!("expected InvalidUdf, got {other:?}"),
3183        }
3184    }
3185
3186    // -------------------------------------------------------------
3187    // VTSI MAT parse
3188    // -------------------------------------------------------------
3189
3190    fn build_vtsi_mat(
3191        ptt_srpt_sector: u32,
3192        pgci_sector: u32,
3193        c_adt_sector: u32,
3194        title_vob_sector: u32,
3195    ) -> Vec<u8> {
3196        let mut b = vec![0u8; 0x200];
3197        b[0..12].copy_from_slice(VTS_MAGIC);
3198        // last_sector_title_set
3199        b[0x000C..0x0010].copy_from_slice(&100_000u32.to_be_bytes());
3200        // last_sector_ifo
3201        b[0x001C..0x0020].copy_from_slice(&15u32.to_be_bytes());
3202        // version 0x0011
3203        b[0x0020..0x0022].copy_from_slice(&0x0011u16.to_be_bytes());
3204        // VTS category = 0
3205        // VTSI_MAT end
3206        b[0x0080..0x0084].copy_from_slice(&0x01FFu32.to_be_bytes());
3207        // menu VOB sector = 0
3208        // title VOB sector
3209        b[0x00C4..0x00C8].copy_from_slice(&title_vob_sector.to_be_bytes());
3210        // PTT_SRPT
3211        b[0x00C8..0x00CC].copy_from_slice(&ptt_srpt_sector.to_be_bytes());
3212        // PGCI
3213        b[0x00CC..0x00D0].copy_from_slice(&pgci_sector.to_be_bytes());
3214        // VTSM_PGCI_UT = 0
3215        // VTS_TMAPTI = 0
3216        // VTSM_C_ADT = 0
3217        // VTSM_VOBU_ADMAP = 0
3218        // VTS_C_ADT
3219        b[0x00E0..0x00E4].copy_from_slice(&c_adt_sector.to_be_bytes());
3220        // VTS_VOBU_ADMAP = 0
3221        b
3222    }
3223
3224    #[test]
3225    fn vtsi_mat_parse_roundtrip() {
3226        let buf = build_vtsi_mat(1, 2, 3, 42);
3227        let mat = VtsiMat::parse(&buf).unwrap();
3228        assert_eq!(mat.last_sector_title_set, 100_000);
3229        assert_eq!(mat.last_sector_ifo, 15);
3230        assert_eq!(mat.version, 0x0011);
3231        assert_eq!(mat.title_vob_sector, 42);
3232        assert_eq!(mat.vts_ptt_srpt_sector, 1);
3233        assert_eq!(mat.vts_pgci_sector, 2);
3234        assert_eq!(mat.vts_c_adt_sector, 3);
3235    }
3236
3237    // -------------------------------------------------------------
3238    // TT_SRPT
3239    // -------------------------------------------------------------
3240
3241    fn build_tt_srpt(entries: &[(u8, u8, u16, u8, u8, u32)]) -> Vec<u8> {
3242        // 8-byte header + N * 12 entries.
3243        let n = entries.len();
3244        let len = 8 + n * 12;
3245        let mut b = vec![0u8; len];
3246        b[0..2].copy_from_slice(&(n as u16).to_be_bytes());
3247        // reserved at 2..4 = 0
3248        // end_address = last byte of last entry, relative to start
3249        let end_addr = (len - 1) as u32;
3250        b[4..8].copy_from_slice(&end_addr.to_be_bytes());
3251        for (i, e) in entries.iter().enumerate() {
3252            let base = 8 + i * 12;
3253            b[base] = e.0; // title_type
3254            b[base + 1] = e.1; // angle_count
3255            b[base + 2..base + 4].copy_from_slice(&e.2.to_be_bytes()); // chapter_count
3256            b[base + 4..base + 6].copy_from_slice(&0u16.to_be_bytes()); // parental_mask
3257            b[base + 6] = e.3; // vts_number
3258            b[base + 7] = e.4; // vts_title_number
3259            b[base + 8..base + 12].copy_from_slice(&e.5.to_be_bytes()); // vts_start_sector
3260            let _ = e; // suppress unused
3261        }
3262        b
3263    }
3264
3265    #[test]
3266    fn tt_srpt_parses_titles() {
3267        // 3 titles: VTS1-title1 (15 chapters), VTS1-title2 (4 chapters),
3268        // VTS2-title1 (1 chapter).
3269        let entries = [
3270            (0x3F, 1u8, 15u16, 1u8, 1u8, 0x0000_0500u32),
3271            (0x3F, 1u8, 4u16, 1u8, 2u8, 0x0000_0500u32),
3272            (0x3F, 2u8, 1u16, 2u8, 1u8, 0x0000_8000u32),
3273        ];
3274        let buf = build_tt_srpt(&entries);
3275        let srpt = TtSrpt::parse(&buf).unwrap();
3276        assert_eq!(srpt.title_count, 3);
3277        assert_eq!(srpt.end_address, (8 + 3 * 12 - 1) as u32);
3278        assert_eq!(srpt.entries[0].chapter_count, 15);
3279        assert_eq!(srpt.entries[1].vts_title_number, 2);
3280        assert_eq!(srpt.entries[2].vts_number, 2);
3281        assert_eq!(srpt.entries[2].angle_count, 2);
3282        assert_eq!(srpt.entries[2].vts_start_sector, 0x0000_8000);
3283    }
3284
3285    // -------------------------------------------------------------
3286    // VTS_C_ADT
3287    // -------------------------------------------------------------
3288
3289    fn build_c_adt(rows: &[(u16, u8, u32, u32)]) -> Vec<u8> {
3290        let n = rows.len();
3291        let len = 8 + n * 12;
3292        let mut b = vec![0u8; len];
3293        // number_of_vob_ids — let's pick the distinct vob count
3294        let distinct = {
3295            let mut v: Vec<u16> = rows.iter().map(|r| r.0).collect();
3296            v.sort();
3297            v.dedup();
3298            v.len() as u16
3299        };
3300        b[0..2].copy_from_slice(&distinct.to_be_bytes());
3301        // end_address = last byte of last entry, relative to start
3302        let end_addr = (len - 1) as u32;
3303        b[4..8].copy_from_slice(&end_addr.to_be_bytes());
3304        for (i, r) in rows.iter().enumerate() {
3305            let base = 8 + i * 12;
3306            b[base..base + 2].copy_from_slice(&r.0.to_be_bytes());
3307            b[base + 2] = r.1;
3308            // reserved at +3 = 0
3309            b[base + 4..base + 8].copy_from_slice(&r.2.to_be_bytes());
3310            b[base + 8..base + 12].copy_from_slice(&r.3.to_be_bytes());
3311        }
3312        b
3313    }
3314
3315    #[test]
3316    fn c_adt_parses_four_rows() {
3317        // 4 cells: VOB 1 → cells 1 + 2 + 3; VOB 2 → cell 1.
3318        let rows = [
3319            (1u16, 1u8, 100u32, 199u32),
3320            (1u16, 2u8, 200u32, 299u32),
3321            (1u16, 3u8, 300u32, 399u32),
3322            (2u16, 1u8, 1000u32, 1999u32),
3323        ];
3324        let buf = build_c_adt(&rows);
3325        let adt = VtsCAdt::parse(&buf).unwrap();
3326        assert_eq!(adt.number_of_vob_ids, 2);
3327        assert_eq!(adt.entries.len(), 4);
3328        assert_eq!(adt.lookup(1, 2), Some((200, 299)));
3329        assert_eq!(adt.lookup(2, 1), Some((1000, 1999)));
3330        assert_eq!(adt.lookup(3, 1), None);
3331    }
3332
3333    // -------------------------------------------------------------
3334    // PGCI with 1 PGC + 3 cells
3335    // -------------------------------------------------------------
3336
3337    fn build_pgc_with_cells(cells: &[CellPlaybackInfo], positions: &[CellPositionInfo]) -> Vec<u8> {
3338        assert_eq!(cells.len(), positions.len());
3339        let n = cells.len();
3340        // PGC header is 0xEC bytes. Then program map (1 program, 1
3341        // byte each, padded to word boundary). Then C_PBI (24*n). Then
3342        // C_POS (4*n).
3343        let header_size = 0xEC;
3344        let prog_count = 1u8;
3345        let prog_map_size = (usize::from(prog_count) + 1) & !1; // pad to word
3346        let pre_n = 1usize; // command table: 1 pre + 1 post + 2 cell = 4 words
3347        let post_n = 1usize;
3348        let cmd_cell_n = 2usize;
3349        let cmd_table_size = 8 + (pre_n + post_n + cmd_cell_n) * 8;
3350        let cpbi_size = n * 24;
3351        let cpos_size = n * 4;
3352        let mut b = vec![0u8; header_size + cmd_table_size + prog_map_size + cpbi_size + cpos_size];
3353
3354        // number_of_programs at 0x0002
3355        b[0x0002] = prog_count;
3356        // number_of_cells at 0x0003
3357        b[0x0003] = n as u8;
3358        // playback time
3359        b[0x0004..0x0008].copy_from_slice(&[0x00, 0x05, 0x00, 0xE0]); // 00:05:00.00 @ 30fps
3360                                                                      // 0x0008..: prohibited UOPs = 0
3361                                                                      // next/prev/goup at 0x009C / 0x009E / 0x00A0 = 0
3362                                                                      // still time, playback mode = 0
3363
3364        // Palette at 0x00A4: 16 × (0, Y, Cr, Cb). Fill entry i with a
3365        // deterministic (Y=0x10+i, Cr=0x80, Cb=0x80) so the round-trip
3366        // can assert the layout decode.
3367        for i in 0..16usize {
3368            let base = 0x00A4 + i * 4;
3369            b[base] = 0x00; // reserved
3370            b[base + 1] = 0x10 + i as u8; // Y
3371            b[base + 2] = 0x80; // Cr
3372            b[base + 3] = 0x80; // Cb
3373        }
3374
3375        let off_cmd = header_size as u16; // command table right after header
3376        let off_pmap = (header_size + cmd_table_size) as u16;
3377        let off_cpbi = (header_size + cmd_table_size + prog_map_size) as u16;
3378        let off_cpos = (header_size + cmd_table_size + prog_map_size + cpbi_size) as u16;
3379        b[0x00E4..0x00E6].copy_from_slice(&off_cmd.to_be_bytes());
3380        b[0x00E6..0x00E8].copy_from_slice(&off_pmap.to_be_bytes());
3381        b[0x00E8..0x00EA].copy_from_slice(&off_cpbi.to_be_bytes());
3382        b[0x00EA..0x00EC].copy_from_slice(&off_cpos.to_be_bytes());
3383
3384        // Command table header + words. Each word is tagged in byte 0
3385        // so the round-trip can tell pre/post/cell apart.
3386        let ct = header_size;
3387        b[ct..ct + 2].copy_from_slice(&(pre_n as u16).to_be_bytes());
3388        b[ct + 2..ct + 4].copy_from_slice(&(post_n as u16).to_be_bytes());
3389        b[ct + 4..ct + 6].copy_from_slice(&(cmd_cell_n as u16).to_be_bytes());
3390        b[ct + 6..ct + 8].copy_from_slice(&((cmd_table_size - 1) as u16).to_be_bytes());
3391        let mut w = ct + 8;
3392        b[w] = 0xA0; // pre[0]: command_type = 0b101
3393        b[w + 7] = 0x01;
3394        w += 8;
3395        b[w] = 0xB0; // post[0]
3396        b[w + 7] = 0x02;
3397        w += 8;
3398        b[w] = 0xC0; // cell[0]
3399        b[w + 7] = 0x03;
3400        w += 8;
3401        b[w] = 0xC1; // cell[1]
3402        b[w + 7] = 0x04;
3403
3404        // program map: 1 program starting at cell 1
3405        b[off_pmap as usize] = 1;
3406        // (no padding needed since prog_map_size already includes pad)
3407
3408        // C_PBI
3409        for (i, c) in cells.iter().enumerate() {
3410            let base = off_cpbi as usize + i * 24;
3411            b[base] = c.category_byte0;
3412            b[base + 1] = if c.restricted { 0x80 } else { 0 };
3413            b[base + 2] = c.still_time;
3414            b[base + 3] = c.cell_command;
3415            // playback time — synthesise a deterministic field
3416            b[base + 4] = 0x00;
3417            b[base + 5] = 0x01;
3418            b[base + 6] = 0x00;
3419            b[base + 7] = 0xE0;
3420            b[base + 8..base + 12].copy_from_slice(&c.first_vobu_start_sector.to_be_bytes());
3421            b[base + 12..base + 16].copy_from_slice(&c.first_ilvu_end_sector.to_be_bytes());
3422            b[base + 16..base + 20].copy_from_slice(&c.last_vobu_start_sector.to_be_bytes());
3423            b[base + 20..base + 24].copy_from_slice(&c.last_vobu_end_sector.to_be_bytes());
3424        }
3425
3426        // C_POS
3427        for (i, p) in positions.iter().enumerate() {
3428            let base = off_cpos as usize + i * 4;
3429            b[base..base + 2].copy_from_slice(&p.vob_id.to_be_bytes());
3430            b[base + 3] = p.cell_id;
3431        }
3432        b
3433    }
3434
3435    fn make_cell(start: u32, end: u32) -> CellPlaybackInfo {
3436        CellPlaybackInfo {
3437            category_byte0: 0,
3438            restricted: false,
3439            still_time: 0,
3440            cell_command: 0,
3441            playback_time: PgcTime::from_bytes([0, 1, 0, 0xE0]),
3442            first_vobu_start_sector: start,
3443            first_ilvu_end_sector: start + 5,
3444            last_vobu_start_sector: end - 5,
3445            last_vobu_end_sector: end,
3446        }
3447    }
3448
3449    #[test]
3450    fn pgci_parses_one_pgc_with_three_cells() {
3451        let cells = [
3452            make_cell(1000, 1999),
3453            make_cell(2000, 2999),
3454            make_cell(3000, 3999),
3455        ];
3456        let positions = [
3457            CellPositionInfo {
3458                vob_id: 1,
3459                cell_id: 1,
3460            },
3461            CellPositionInfo {
3462                vob_id: 1,
3463                cell_id: 2,
3464            },
3465            CellPositionInfo {
3466                vob_id: 1,
3467                cell_id: 3,
3468            },
3469        ];
3470        let pgc_blob = build_pgc_with_cells(&cells, &positions);
3471
3472        // Wrap that single PGC into a PGCI: 8-byte header + 1 SRP
3473        // entry (8 bytes) + the PGC body.
3474        let srp_size = 8usize;
3475        let body_off = 8 + srp_size; // PGC starts here
3476        let total = body_off + pgc_blob.len();
3477        let mut b = vec![0u8; total];
3478        // number_of_pgcs = 1
3479        b[0..2].copy_from_slice(&1u16.to_be_bytes());
3480        // reserved at 2..4 = 0
3481        // end_address = total - 1
3482        b[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes());
3483        // SRP[0]: category 0, offset = body_off
3484        b[8..12].copy_from_slice(&0u32.to_be_bytes());
3485        b[12..16].copy_from_slice(&(body_off as u32).to_be_bytes());
3486        // Copy PGC body
3487        b[body_off..body_off + pgc_blob.len()].copy_from_slice(&pgc_blob);
3488
3489        let pgci = Pgci::parse(&b).unwrap();
3490        assert_eq!(pgci.number_of_pgcs, 1);
3491        assert_eq!(pgci.pgcs.len(), 1);
3492        let pgc = &pgci.pgcs[0];
3493        assert_eq!(pgc.number_of_programs, 1);
3494        assert_eq!(pgc.number_of_cells, 3);
3495        assert_eq!(pgc.cells.len(), 3);
3496        assert_eq!(pgc.cell_positions.len(), 3);
3497        assert_eq!(pgc.cells[0].first_vobu_start_sector, 1000);
3498        assert_eq!(pgc.cells[2].last_vobu_end_sector, 3999);
3499        assert_eq!(pgc.cell_positions[1].cell_id, 2);
3500        assert_eq!(pgc.playback_time.frame_rate, FrameRate::Ntsc30);
3501
3502        // Palette decode: entry i carries (Y=0x10+i, Cr=0x80, Cb=0x80).
3503        assert_eq!(
3504            pgc.palette[0],
3505            PaletteEntry {
3506                y: 0x10,
3507                cr: 0x80,
3508                cb: 0x80
3509            }
3510        );
3511        assert_eq!(
3512            pgc.palette[15],
3513            PaletteEntry {
3514                y: 0x1F,
3515                cr: 0x80,
3516                cb: 0x80
3517            }
3518        );
3519
3520        // Command table: 1 pre + 1 post + 2 cell, tagged in byte 0.
3521        let cmds = pgc.commands.as_ref().expect("command table present");
3522        assert_eq!(cmds.pre.len(), 1);
3523        assert_eq!(cmds.post.len(), 1);
3524        assert_eq!(cmds.cell.len(), 2);
3525        assert_eq!(cmds.pre[0].bytes[0], 0xA0);
3526        assert_eq!(cmds.pre[0].bytes[7], 0x01);
3527        assert_eq!(cmds.pre[0].command_type(), 0b101);
3528        assert_eq!(cmds.post[0].bytes[0], 0xB0);
3529        assert_eq!(cmds.post[0].bytes[7], 0x02);
3530        assert_eq!(cmds.cell[0].bytes[7], 0x03);
3531        assert_eq!(cmds.cell[1].bytes[7], 0x04);
3532    }
3533
3534    // -------------------------------------------------------------
3535    // PGC palette + command table — focused unit tests
3536    // -------------------------------------------------------------
3537
3538    #[test]
3539    fn palette_entry_skips_reserved_byte() {
3540        // (reserved, Y, Cr, Cb)
3541        let e = PaletteEntry::parse(&[0xFF, 0x42, 0x10, 0xC0]).unwrap();
3542        assert_eq!(
3543            e,
3544            PaletteEntry {
3545                y: 0x42,
3546                cr: 0x10,
3547                cb: 0xC0
3548            }
3549        );
3550        // Too short → error.
3551        assert!(PaletteEntry::parse(&[0x00, 0x01, 0x02]).is_err());
3552    }
3553
3554    #[test]
3555    fn command_table_carves_three_lists() {
3556        // 2 pre + 1 post + 1 cell = 4 words.
3557        let pre = 2u16;
3558        let post = 1u16;
3559        let cell = 1u16;
3560        let total = (pre + post + cell) as usize;
3561        let size = 8 + total * 8;
3562        let mut b = vec![0u8; size];
3563        b[0..2].copy_from_slice(&pre.to_be_bytes());
3564        b[2..4].copy_from_slice(&post.to_be_bytes());
3565        b[4..6].copy_from_slice(&cell.to_be_bytes());
3566        b[6..8].copy_from_slice(&((size - 1) as u16).to_be_bytes());
3567        // Tag each word's last byte with its 1-based index.
3568        for i in 0..total {
3569            b[8 + i * 8 + 7] = (i + 1) as u8;
3570        }
3571        let t = PgcCommandTable::parse(&b).unwrap();
3572        assert_eq!(t.pre.len(), 2);
3573        assert_eq!(t.post.len(), 1);
3574        assert_eq!(t.cell.len(), 1);
3575        assert_eq!(t.end_address, (size - 1) as u16);
3576        // pre = words 1,2; post = word 3; cell = word 4.
3577        assert_eq!(t.pre[0].bytes[7], 1);
3578        assert_eq!(t.pre[1].bytes[7], 2);
3579        assert_eq!(t.post[0].bytes[7], 3);
3580        assert_eq!(t.cell[0].bytes[7], 4);
3581    }
3582
3583    #[test]
3584    fn command_table_rejects_overlong_count() {
3585        // pre alone claims 129 words → > 128 invariant violated.
3586        let mut b = vec![0u8; 8];
3587        b[0..2].copy_from_slice(&129u16.to_be_bytes());
3588        assert!(PgcCommandTable::parse(&b).is_err());
3589    }
3590
3591    #[test]
3592    fn command_table_rejects_truncated_list() {
3593        // Header claims 2 words but the buffer only holds one.
3594        let mut b = vec![0u8; 8 + 8];
3595        b[0..2].copy_from_slice(&2u16.to_be_bytes());
3596        assert!(PgcCommandTable::parse(&b).is_err());
3597    }
3598
3599    // -------------------------------------------------------------
3600    // PgcCommandTable — typed-instruction iterator surface
3601    // -------------------------------------------------------------
3602
3603    /// Build a synthetic `PgcCommandTable` with 1 pre + 1 post +
3604    /// 3 cell words covering distinct instruction types so the
3605    /// iterators below have something to discriminate on.
3606    fn synth_command_table() -> PgcCommandTable {
3607        let pre = 1u16;
3608        let post = 1u16;
3609        let cell = 3u16;
3610        let total = (pre + post + cell) as usize;
3611        let size = 8 + total * 8;
3612        let mut b = vec![0u8; size];
3613        b[0..2].copy_from_slice(&pre.to_be_bytes());
3614        b[2..4].copy_from_slice(&post.to_be_bytes());
3615        b[4..6].copy_from_slice(&cell.to_be_bytes());
3616        b[6..8].copy_from_slice(&((size - 1) as u16).to_be_bytes());
3617        // Word layout (per mpucoder-vmi.html "command word layout"):
3618        //   byte 0 = TTT D SSSS where TTT = type (bits 7..5),
3619        //     D = SET-direct flag (bit 4), SSSS = SET-op nibble.
3620        //   byte 1 = C HHH CCCC where C = CMP-direct (bit 7),
3621        //     HHH = CMP-op (bits 6..4), CCCC = cmd_nibble (bits 3..0).
3622        // pre[0] stays all-zero at offset 8 → Type 0 + cmd_nibble 0
3623        //   = NOP per `decode_type0`.
3624        // post[0]: Type 1 jumpcall + cmd_nibble 1 = Exit. Byte 0
3625        //   needs the SET-direct bit (D=1) so the dispatcher routes
3626        //   to `decode_type1_jumpcall`.
3627        let post_off = 8 + 8;
3628        b[post_off] = 0b0011_0000;
3629        b[post_off + 1] = 0x01;
3630        // cell[0]: same Type 1 Exit encoding as post[0].
3631        let cell0 = post_off + 8;
3632        b[cell0] = 0b0011_0000;
3633        b[cell0 + 1] = 0x01;
3634        // cell[1]: Type 0 Break — byte 0 = 0, cmd_nibble = 2.
3635        let cell1 = cell0 + 8;
3636        b[cell1] = 0x00;
3637        b[cell1 + 1] = 0x02;
3638        // cell[2]: Type 0 NOP — all zeros (already).
3639        PgcCommandTable::parse(&b).unwrap()
3640    }
3641
3642    #[test]
3643    fn pgc_cmd_table_typed_iterators_walk_all_three_lists() {
3644        let t = synth_command_table();
3645        let pre: Vec<_> = t.pre_instructions().collect();
3646        let post: Vec<_> = t.post_instructions().collect();
3647        let cell: Vec<_> = t.cell_instructions().collect();
3648        assert_eq!(pre.len(), 1);
3649        assert_eq!(post.len(), 1);
3650        assert_eq!(cell.len(), 3);
3651        // pre[0] = NOP per the synth payload.
3652        assert!(matches!(pre[0], crate::nav::NavInstruction::Nop));
3653        // post[0] = Exit per the Type 1 / link-family `0x01` encoding.
3654        assert!(matches!(post[0], crate::nav::NavInstruction::Exit));
3655        // cell[0] = Exit; cell[1] = Break; cell[2] = NOP.
3656        assert!(matches!(cell[0], crate::nav::NavInstruction::Exit));
3657        assert!(matches!(cell[1], crate::nav::NavInstruction::Break));
3658        assert!(matches!(cell[2], crate::nav::NavInstruction::Nop));
3659    }
3660
3661    #[test]
3662    fn pgc_cmd_table_cell_instruction_uses_one_based_index() {
3663        let t = synth_command_table();
3664        // Spec: cell_command = 0 means "no command associated"; the
3665        // accessor returns None.
3666        assert!(t.cell_instruction(0).is_none());
3667        // 1-based indexing — index 1 picks cell[0] = Exit.
3668        assert!(matches!(
3669            t.cell_instruction(1),
3670            Some(crate::nav::NavInstruction::Exit)
3671        ));
3672        // index 2 picks cell[1] = Break.
3673        assert!(matches!(
3674            t.cell_instruction(2),
3675            Some(crate::nav::NavInstruction::Break)
3676        ));
3677        // index 3 picks cell[2] = NOP.
3678        assert!(matches!(
3679            t.cell_instruction(3),
3680            Some(crate::nav::NavInstruction::Nop)
3681        ));
3682        // Out-of-range index returns None rather than panicking.
3683        assert!(t.cell_instruction(4).is_none());
3684        assert!(t.cell_instruction(u16::MAX).is_none());
3685    }
3686
3687    #[test]
3688    fn nav_command_decode_instruction_matches_nav_decode() {
3689        // Same NavCommand word reached through the IFO-side
3690        // convenience and the explicit `nav` disassembler should
3691        // yield the same typed instruction.
3692        let mut bytes = [0u8; 8];
3693        bytes[0] = 0b0011_0000; // Type 1 + SET-direct → jumpcall family.
3694        bytes[1] = 0x01; // cmd_nibble = 1 → Exit.
3695        let raw = NavCommand { bytes };
3696        assert_eq!(raw.decode_instruction(), raw.decode());
3697        assert!(matches!(
3698            raw.decode_instruction(),
3699            crate::nav::NavInstruction::Exit
3700        ));
3701    }
3702
3703    #[test]
3704    fn pgc_without_command_table_yields_none() {
3705        // build_pgc_with_cells always emits a command table; build a
3706        // minimal header-only PGC with offset_commands == 0.
3707        let mut b = vec![0u8; 0xEC];
3708        b[0x0002] = 0; // 0 programs
3709        b[0x0003] = 0; // 0 cells
3710        b[0x0004..0x0008].copy_from_slice(&[0x00, 0x00, 0x00, 0xC0]); // PAL 25 fps
3711                                                                      // all four table offsets stay 0
3712        let pgc = Pgc::parse(&b).unwrap();
3713        assert!(pgc.commands.is_none());
3714        // Palette defaults to all-zero when bytes are zero.
3715        assert_eq!(pgc.palette[7], PaletteEntry::default());
3716    }
3717
3718    // -------------------------------------------------------------
3719    // VTS_PTT_SRPT walking 2 titles × 5 chapters
3720    // -------------------------------------------------------------
3721
3722    #[test]
3723    fn ptt_srpt_walks_two_titles_five_chapters() {
3724        // 2 titles × 5 chapters each = 10 PTT entries × 4 bytes = 40 bytes
3725        // of chapter data. Plus 8-byte header + 2 × 4-byte offsets = 16
3726        // bytes of header. Total = 56 bytes.
3727        let n_titles = 2usize;
3728        let n_chaps = 5usize;
3729        let offsets_size = n_titles * 4;
3730        let header_size = 8 + offsets_size;
3731        let title_body_size = n_chaps * 4;
3732        let total = header_size + n_titles * title_body_size;
3733        let mut b = vec![0u8; total];
3734        // number_of_titles
3735        b[0..2].copy_from_slice(&(n_titles as u16).to_be_bytes());
3736        // end_address = total - 1
3737        b[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes());
3738        // offset_to_PTT[1] = header_size
3739        // offset_to_PTT[2] = header_size + title_body_size
3740        for ti in 0..n_titles {
3741            let off = (header_size + ti * title_body_size) as u32;
3742            b[8 + ti * 4..8 + ti * 4 + 4].copy_from_slice(&off.to_be_bytes());
3743        }
3744        // Fill chapter entries: PGCN = title_number, PGN = chapter_number
3745        for ti in 0..n_titles {
3746            for ci in 0..n_chaps {
3747                let base = header_size + ti * title_body_size + ci * 4;
3748                let pgcn = (ti + 1) as u16;
3749                let pgn = (ci + 1) as u16;
3750                b[base..base + 2].copy_from_slice(&pgcn.to_be_bytes());
3751                b[base + 2..base + 4].copy_from_slice(&pgn.to_be_bytes());
3752            }
3753        }
3754
3755        let srpt = VtsPttSrpt::parse(&b).unwrap();
3756        assert_eq!(srpt.title_count, 2);
3757        assert_eq!(srpt.titles.len(), 2);
3758        for ti in 0..n_titles {
3759            assert_eq!(srpt.titles[ti].chapters.len(), 5);
3760            assert_eq!(srpt.titles[ti].chapters[0].pgcn, (ti + 1) as u16);
3761            assert_eq!(srpt.titles[ti].chapters[0].pgn, 1);
3762            assert_eq!(srpt.titles[ti].chapters[4].pgn, 5);
3763        }
3764    }
3765
3766    // -------------------------------------------------------------
3767    // Round-trip composite: VTSI_MAT + PTT_SRPT + PGCI + C_ADT
3768    // -------------------------------------------------------------
3769
3770    fn make_composite_vts() -> Vec<u8> {
3771        // We lay out a minimal IFO image:
3772        //   sector 0: VTSI_MAT
3773        //   sector 1: VTS_PTT_SRPT (1 title × 3 chapters)
3774        //   sector 2: VTS_PGCI (1 PGC with 5 cells; 3 programs)
3775        //   sector 3: VTS_C_ADT (5 cell entries)
3776        let mut img = vec![0u8; DVD_SECTOR * 4];
3777
3778        // ---------- Sector 0: VTSI_MAT ----------
3779        let mat = build_vtsi_mat(1, 2, 3, 100);
3780        img[0..mat.len()].copy_from_slice(&mat);
3781
3782        // ---------- Sector 2: VTS_PGCI ----------
3783        // 1 PGC with 5 cells (and 3 programs: cells 1, 3, 5 are
3784        // program entry points). Cells: 1, 2, 3, 4, 5 with disjoint
3785        // sector ranges.
3786        let cells: Vec<CellPlaybackInfo> = (0..5)
3787            .map(|i| make_cell(1000 + i * 1000, 1999 + i * 1000))
3788            .collect();
3789        let positions: Vec<CellPositionInfo> = (0..5)
3790            .map(|i| CellPositionInfo {
3791                vob_id: 1,
3792                cell_id: (i + 1) as u8,
3793            })
3794            .collect();
3795        // build_pgc_with_cells assumes 1 program; we extend manually
3796        // to 3 programs whose program_map = [1, 3, 5].
3797        let header_size = 0xEC;
3798        let prog_count = 3u8;
3799        let prog_map_size = (usize::from(prog_count) + 1) & !1; // 4
3800        let cpbi_size = 5 * 24;
3801        let cpos_size = 5 * 4;
3802        let pgc_blob_len = header_size + prog_map_size + cpbi_size + cpos_size;
3803        let mut pgc_blob = vec![0u8; pgc_blob_len];
3804        pgc_blob[0x0002] = prog_count;
3805        pgc_blob[0x0003] = 5; // number_of_cells
3806        pgc_blob[0x0004..0x0008].copy_from_slice(&[0x00, 0x15, 0x00, 0xE0]);
3807        let off_pmap = header_size as u16;
3808        let off_cpbi = (header_size + prog_map_size) as u16;
3809        let off_cpos = (header_size + prog_map_size + cpbi_size) as u16;
3810        pgc_blob[0x00E6..0x00E8].copy_from_slice(&off_pmap.to_be_bytes());
3811        pgc_blob[0x00E8..0x00EA].copy_from_slice(&off_cpbi.to_be_bytes());
3812        pgc_blob[0x00EA..0x00EC].copy_from_slice(&off_cpos.to_be_bytes());
3813        pgc_blob[header_size] = 1; // program 1 starts at cell 1
3814        pgc_blob[header_size + 1] = 3; // program 2 starts at cell 3
3815        pgc_blob[header_size + 2] = 5; // program 3 starts at cell 5
3816        for (i, c) in cells.iter().enumerate() {
3817            let base = header_size + prog_map_size + i * 24;
3818            pgc_blob[base + 4..base + 8].copy_from_slice(&[0, 1, 0, 0xE0]);
3819            pgc_blob[base + 8..base + 12].copy_from_slice(&c.first_vobu_start_sector.to_be_bytes());
3820            pgc_blob[base + 12..base + 16].copy_from_slice(&c.first_ilvu_end_sector.to_be_bytes());
3821            pgc_blob[base + 16..base + 20].copy_from_slice(&c.last_vobu_start_sector.to_be_bytes());
3822            pgc_blob[base + 20..base + 24].copy_from_slice(&c.last_vobu_end_sector.to_be_bytes());
3823        }
3824        for (i, p) in positions.iter().enumerate() {
3825            let base = header_size + prog_map_size + cpbi_size + i * 4;
3826            pgc_blob[base..base + 2].copy_from_slice(&p.vob_id.to_be_bytes());
3827            pgc_blob[base + 3] = p.cell_id;
3828        }
3829        // Wrap into PGCI
3830        let srp_size = 8usize;
3831        let body_off = 8 + srp_size;
3832        let pgci_total = body_off + pgc_blob.len();
3833        let mut pgci = vec![0u8; pgci_total];
3834        pgci[0..2].copy_from_slice(&1u16.to_be_bytes());
3835        pgci[4..8].copy_from_slice(&((pgci_total - 1) as u32).to_be_bytes());
3836        pgci[12..16].copy_from_slice(&(body_off as u32).to_be_bytes());
3837        pgci[body_off..body_off + pgc_blob.len()].copy_from_slice(&pgc_blob);
3838        img[2 * DVD_SECTOR..2 * DVD_SECTOR + pgci.len()].copy_from_slice(&pgci);
3839
3840        // ---------- Sector 1: VTS_PTT_SRPT ----------
3841        // 1 title with 3 chapters at programs 1, 2, 3.
3842        let n_titles = 1usize;
3843        let n_chaps = 3usize;
3844        let header_sz = 8 + n_titles * 4;
3845        let title_body = n_chaps * 4;
3846        let total = header_sz + title_body;
3847        let mut ptt = vec![0u8; total];
3848        ptt[0..2].copy_from_slice(&(n_titles as u16).to_be_bytes());
3849        ptt[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes());
3850        ptt[8..12].copy_from_slice(&(header_sz as u32).to_be_bytes());
3851        for ci in 0..n_chaps {
3852            let base = header_sz + ci * 4;
3853            ptt[base..base + 2].copy_from_slice(&1u16.to_be_bytes()); // PGCN
3854            ptt[base + 2..base + 4].copy_from_slice(&((ci + 1) as u16).to_be_bytes());
3855            // PGN
3856        }
3857        img[DVD_SECTOR..DVD_SECTOR + ptt.len()].copy_from_slice(&ptt);
3858
3859        // ---------- Sector 3: VTS_C_ADT ----------
3860        let cadt_rows: Vec<(u16, u8, u32, u32)> = (0..5)
3861            .map(|i| {
3862                (
3863                    1u16,
3864                    (i + 1) as u8,
3865                    1000 + i as u32 * 1000,
3866                    1999 + i as u32 * 1000,
3867                )
3868            })
3869            .collect();
3870        let cadt = build_c_adt(&cadt_rows);
3871        img[3 * DVD_SECTOR..3 * DVD_SECTOR + cadt.len()].copy_from_slice(&cadt);
3872
3873        img
3874    }
3875
3876    #[test]
3877    fn composite_vts_roundtrip() {
3878        let img = make_composite_vts();
3879        let vts = VtsIfo::parse(&img, 1).unwrap();
3880        assert_eq!(vts.vts_number, 1);
3881        assert_eq!(vts.title_count, 1);
3882        assert_eq!(vts.pgcs.len(), 1);
3883        // The PGC has 5 cells and 3 programs.
3884        assert_eq!(vts.pgcs[0].number_of_cells, 5);
3885        assert_eq!(vts.pgcs[0].number_of_programs, 3);
3886        // Chapter materialisation:
3887        let t = &vts.titles[0];
3888        assert_eq!(t.chapter_count, 3);
3889        // program_map = [1, 3, 5]; PTT[1] = (PGCN=1, PGN=1) →
3890        // start_cell=1, end_cell=2 (next program's start_cell - 1 = 3-1).
3891        assert_eq!(t.chapters[0].start_cell, 1);
3892        assert_eq!(t.chapters[0].end_cell, 2);
3893        // PTT[2] = (PGCN=1, PGN=2) → start_cell=3, end_cell=4.
3894        assert_eq!(t.chapters[1].start_cell, 3);
3895        assert_eq!(t.chapters[1].end_cell, 4);
3896        // PTT[3] = (PGCN=1, PGN=3) → start_cell=5, end_cell=5 (last PGC cell).
3897        assert_eq!(t.chapters[2].start_cell, 5);
3898        assert_eq!(t.chapters[2].end_cell, 5);
3899        // C_ADT must give us first cell's sector range.
3900        assert_eq!(vts.cell_adt.lookup(1, 1), Some((1000, 1999)));
3901        assert_eq!(vts.cell_adt.lookup(1, 5), Some((5000, 5999)));
3902        // The composite IFO was built without VOBU_ADMAP / TMAPTI
3903        // sector pointers — both materialised tables must be `None`.
3904        assert!(vts.vobu_admap.is_none());
3905        assert!(vts.time_map.is_none());
3906    }
3907
3908    // -------------------------------------------------------------
3909    // VOBU_ADMAP
3910    // -------------------------------------------------------------
3911
3912    fn build_vobu_admap(entries: &[u32]) -> Vec<u8> {
3913        // 4-byte end_address + N × 4-byte sector words.
3914        let n = entries.len();
3915        let len = 4 + n * 4;
3916        let mut b = vec![0u8; len];
3917        let end_addr = (len - 1) as u32;
3918        b[0..4].copy_from_slice(&end_addr.to_be_bytes());
3919        for (i, s) in entries.iter().enumerate() {
3920            b[4 + i * 4..4 + (i + 1) * 4].copy_from_slice(&s.to_be_bytes());
3921        }
3922        b
3923    }
3924
3925    #[test]
3926    fn vobu_admap_parses_three_vobus() {
3927        let entries = [0u32, 200u32, 450u32];
3928        let buf = build_vobu_admap(&entries);
3929        let map = VobuAdmap::parse(&buf).unwrap();
3930        assert_eq!(map.vobu_count(), 3);
3931        assert_eq!(map.entries, entries);
3932        assert_eq!(map.end_address, (buf.len() - 1) as u32);
3933        // 1-based VOBU lookup.
3934        assert_eq!(map.vobu_start_sector(1), Some(0));
3935        assert_eq!(map.vobu_start_sector(2), Some(200));
3936        assert_eq!(map.vobu_start_sector(3), Some(450));
3937        assert_eq!(map.vobu_start_sector(4), None);
3938        assert_eq!(map.vobu_start_sector(0), None);
3939    }
3940
3941    #[test]
3942    fn vobu_admap_partition_locates_containing_vobu() {
3943        // VOBUs start at sectors 0, 100, 300, 600.
3944        let buf = build_vobu_admap(&[0, 100, 300, 600]);
3945        let map = VobuAdmap::parse(&buf).unwrap();
3946        // Boundary-inclusive: a sector that matches an entry exactly
3947        // belongs to that VOBU.
3948        assert_eq!(map.vobu_containing(0), Some(1));
3949        assert_eq!(map.vobu_containing(99), Some(1));
3950        assert_eq!(map.vobu_containing(100), Some(2));
3951        assert_eq!(map.vobu_containing(299), Some(2));
3952        assert_eq!(map.vobu_containing(300), Some(3));
3953        assert_eq!(map.vobu_containing(599), Some(3));
3954        assert_eq!(map.vobu_containing(600), Some(4));
3955        // Far past — still maps to the last VOBU (its end is unknown
3956        // until the next entry, so the partition returns the last one).
3957        assert_eq!(map.vobu_containing(1_000_000), Some(4));
3958    }
3959
3960    #[test]
3961    fn vobu_admap_first_entry_above_zero_returns_none_for_pre_sector() {
3962        // Map's first VOBU starts at sector 100; sector 50 falls
3963        // before it, so the lookup must return `None`.
3964        let buf = build_vobu_admap(&[100, 200, 300]);
3965        let map = VobuAdmap::parse(&buf).unwrap();
3966        assert_eq!(map.vobu_containing(0), None);
3967        assert_eq!(map.vobu_containing(99), None);
3968        assert_eq!(map.vobu_containing(100), Some(1));
3969    }
3970
3971    #[test]
3972    fn vobu_admap_empty_map_lookups_return_none() {
3973        // end_address = 3 → body span = 0 bytes → zero entries.
3974        let mut b = vec![0u8; 4];
3975        b[0..4].copy_from_slice(&3u32.to_be_bytes());
3976        let map = VobuAdmap::parse(&b).unwrap();
3977        assert_eq!(map.vobu_count(), 0);
3978        assert_eq!(map.vobu_start_sector(1), None);
3979        assert_eq!(map.vobu_containing(0), None);
3980    }
3981
3982    #[test]
3983    fn vobu_admap_rejects_non_multiple_end_address() {
3984        // end_address = 5 implies a 2-byte body span — not a
3985        // multiple of the 4-byte entry size.
3986        let mut b = vec![0u8; 8];
3987        b[0..4].copy_from_slice(&5u32.to_be_bytes());
3988        assert!(VobuAdmap::parse(&b).is_err());
3989    }
3990
3991    #[test]
3992    fn vobu_admap_rejects_truncated_buffer() {
3993        // end_address = 11 implies 2 entries (8 body bytes), but
3994        // the buffer is only 4 + 4 = 8 bytes long, missing the
3995        // second entry.
3996        let mut b = vec![0u8; 4 + 4];
3997        b[0..4].copy_from_slice(&11u32.to_be_bytes());
3998        assert!(VobuAdmap::parse(&b).is_err());
3999    }
4000
4001    // -------------------------------------------------------------
4002    // VTS_TMAP / VTS_TMAPTI
4003    // -------------------------------------------------------------
4004
4005    fn build_tmap(time_unit: u8, entries: &[(u32, bool)]) -> Vec<u8> {
4006        let n = entries.len();
4007        let len = 4 + n * 4;
4008        let mut b = vec![0u8; len];
4009        b[0] = time_unit;
4010        // byte 1 reserved
4011        b[2..4].copy_from_slice(&(n as u16).to_be_bytes());
4012        for (i, (sector, disc)) in entries.iter().enumerate() {
4013            let mut raw = *sector & TmapEntry::SECTOR_MASK;
4014            if *disc {
4015                raw |= TmapEntry::DISCONTINUITY_BIT;
4016            }
4017            b[4 + i * 4..4 + (i + 1) * 4].copy_from_slice(&raw.to_be_bytes());
4018        }
4019        b
4020    }
4021
4022    #[test]
4023    fn tmap_decodes_entries_and_discontinuity() {
4024        // Three 4-second steps, second entry flagged discontinuous.
4025        let buf = build_tmap(4, &[(100, false), (250, true), (400, false)]);
4026        let map = VtsTmap::parse(&buf).unwrap();
4027        assert_eq!(map.time_unit, 4);
4028        assert_eq!(map.entries.len(), 3);
4029        assert_eq!(
4030            map.entries[0],
4031            TmapEntry {
4032                sector: 100,
4033                discontinuous: false
4034            }
4035        );
4036        assert_eq!(
4037            map.entries[1],
4038            TmapEntry {
4039                sector: 250,
4040                discontinuous: true
4041            }
4042        );
4043        assert_eq!(map.total_seconds(), 12);
4044    }
4045
4046    #[test]
4047    fn tmap_sector_at_brackets_seconds_per_time_unit() {
4048        // 5-second steps; first entry covers [0,5), second [5,10),
4049        // third [10,15).
4050        let buf = build_tmap(5, &[(10, false), (20, false), (30, false)]);
4051        let map = VtsTmap::parse(&buf).unwrap();
4052        assert_eq!(map.sector_at(0), Some(10));
4053        assert_eq!(map.sector_at(4), Some(10));
4054        assert_eq!(map.sector_at(5), Some(20));
4055        assert_eq!(map.sector_at(9), Some(20));
4056        assert_eq!(map.sector_at(10), Some(30));
4057        assert_eq!(map.sector_at(14), Some(30));
4058        // Past the last bracket: clamp to the last entry rather than
4059        // return `None` so playback engines that pass an inaccurate
4060        // wall-clock get a reasonable "seek to end" answer.
4061        assert_eq!(map.sector_at(15), Some(30));
4062        assert_eq!(map.sector_at(1_000_000), Some(30));
4063    }
4064
4065    #[test]
4066    fn tmap_empty_map_yields_no_sector() {
4067        // number_of_entries = 0 → empty entry table → all lookups
4068        // return `None`.
4069        let buf = build_tmap(2, &[]);
4070        let map = VtsTmap::parse(&buf).unwrap();
4071        assert_eq!(map.time_unit, 2);
4072        assert!(map.entries.is_empty());
4073        assert_eq!(map.sector_at(0), None);
4074        assert_eq!(map.sector_at(60), None);
4075        assert_eq!(map.total_seconds(), 0);
4076    }
4077
4078    #[test]
4079    fn tmap_zero_time_unit_yields_no_sector() {
4080        // time_unit = 0 with a populated entry table is a malformed
4081        // map — sector_at would divide by zero, so we explicitly
4082        // surface `None` instead.
4083        let buf = build_tmap(0, &[(1, false), (2, false)]);
4084        let map = VtsTmap::parse(&buf).unwrap();
4085        assert_eq!(map.sector_at(0), None);
4086    }
4087
4088    #[test]
4089    fn tmap_rejects_truncated_buffer() {
4090        // number_of_entries = 2 (8 body bytes needed) but buffer
4091        // only carries 4 + 4 = 8 bytes — one entry missing.
4092        let mut b = vec![0u8; 4 + 4];
4093        b[0] = 1;
4094        b[2..4].copy_from_slice(&2u16.to_be_bytes());
4095        assert!(VtsTmap::parse(&b).is_err());
4096    }
4097
4098    fn build_tmapti(maps: &[Vec<u8>]) -> Vec<u8> {
4099        // 8-byte header + N × 4-byte offsets + concatenated map bodies.
4100        let n = maps.len();
4101        let offsets_size = n * 4;
4102        let header_size = 8 + offsets_size;
4103        let body_size: usize = maps.iter().map(|m| m.len()).sum();
4104        let total = header_size + body_size;
4105        let mut b = vec![0u8; total];
4106        b[0..2].copy_from_slice(&(n as u16).to_be_bytes());
4107        // reserved at 2..4 = 0
4108        b[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes());
4109        let mut cursor = header_size;
4110        for (i, m) in maps.iter().enumerate() {
4111            let off = cursor as u32;
4112            b[8 + i * 4..8 + (i + 1) * 4].copy_from_slice(&off.to_be_bytes());
4113            b[cursor..cursor + m.len()].copy_from_slice(m);
4114            cursor += m.len();
4115        }
4116        b
4117    }
4118
4119    #[test]
4120    fn tmapti_walks_two_pgc_maps() {
4121        let map_a = build_tmap(2, &[(0, false), (50, false), (100, false)]);
4122        let map_b = build_tmap(3, &[(200, false), (400, true)]);
4123        let buf = build_tmapti(&[map_a, map_b]);
4124        let table = VtsTmapti::parse(&buf).unwrap();
4125        assert_eq!(table.number_of_pgcs, 2);
4126        assert_eq!(table.maps.len(), 2);
4127        // PGCN lookups are 1-based.
4128        let m1 = table.get(1).unwrap();
4129        assert_eq!(m1.time_unit, 2);
4130        assert_eq!(m1.entries.len(), 3);
4131        let m2 = table.get(2).unwrap();
4132        assert_eq!(m2.time_unit, 3);
4133        assert!(m2.entries[1].discontinuous);
4134        assert_eq!(table.get(0), None);
4135        assert_eq!(table.get(3), None);
4136    }
4137
4138    #[test]
4139    fn tmapti_carries_empty_map_per_spec_invariant() {
4140        // The spec mandates "each PGC MUST have a time map, even if
4141        // it is empty" — make sure an empty map decodes cleanly when
4142        // the offset list points at it.
4143        let empty = build_tmap(0, &[]);
4144        let buf = build_tmapti(&[empty]);
4145        let table = VtsTmapti::parse(&buf).unwrap();
4146        assert_eq!(table.number_of_pgcs, 1);
4147        let m = table.get(1).unwrap();
4148        assert!(m.entries.is_empty());
4149        assert_eq!(m.sector_at(0), None);
4150    }
4151
4152    #[test]
4153    fn tmapti_rejects_short_offset_list() {
4154        // number_of_pgcs = 3 but only 8 bytes available — the offset
4155        // list runs past the buffer end.
4156        let mut b = vec![0u8; 8];
4157        b[0..2].copy_from_slice(&3u16.to_be_bytes());
4158        assert!(VtsTmapti::parse(&b).is_err());
4159    }
4160
4161    // -------------------------------------------------------------
4162    // Composite VTS round-trip with VOBU_ADMAP + TMAPTI populated
4163    // -------------------------------------------------------------
4164
4165    fn make_composite_vts_with_admap_and_tmap() -> Vec<u8> {
4166        // Sector layout:
4167        //   sector 0: VTSI_MAT
4168        //   sector 1: VTS_PTT_SRPT (1 title × 3 chapters)
4169        //   sector 2: VTS_PGCI (1 PGC, 5 cells, 3 programs)
4170        //   sector 3: VTS_C_ADT (5 cell entries)
4171        //   sector 4: VTS_VOBU_ADMAP (4 VOBUs)
4172        //   sector 5: VTS_TMAPTI (1 PGC × 3 steps)
4173        let mut img = vec![0u8; DVD_SECTOR * 6];
4174
4175        // VTSI_MAT — populate the four sector pointers we exercise.
4176        // build_vtsi_mat zeroes the TMAPTI + VOBU_ADMAP pointers,
4177        // so patch them in manually after the base copy.
4178        let mat = build_vtsi_mat(1, 2, 3, 100);
4179        img[0..mat.len()].copy_from_slice(&mat);
4180        img[0x00D4..0x00D8].copy_from_slice(&5u32.to_be_bytes()); // TMAPTI sector
4181        img[0x00E4..0x00E8].copy_from_slice(&4u32.to_be_bytes()); // VOBU_ADMAP sector
4182
4183        // PGCI — re-use the helper that already builds 1 PGC with 5
4184        // cells + 3 programs.
4185        let cells: Vec<CellPlaybackInfo> = (0..5)
4186            .map(|i| make_cell(1000 + i * 1000, 1999 + i * 1000))
4187            .collect();
4188        let positions: Vec<CellPositionInfo> = (0..5)
4189            .map(|i| CellPositionInfo {
4190                vob_id: 1,
4191                cell_id: (i + 1) as u8,
4192            })
4193            .collect();
4194        let header_size = 0xEC;
4195        let prog_count = 3u8;
4196        let prog_map_size = (usize::from(prog_count) + 1) & !1;
4197        let cpbi_size = 5 * 24;
4198        let cpos_size = 5 * 4;
4199        let pgc_blob_len = header_size + prog_map_size + cpbi_size + cpos_size;
4200        let mut pgc_blob = vec![0u8; pgc_blob_len];
4201        pgc_blob[0x0002] = prog_count;
4202        pgc_blob[0x0003] = 5;
4203        pgc_blob[0x0004..0x0008].copy_from_slice(&[0x00, 0x15, 0x00, 0xE0]);
4204        let off_pmap = header_size as u16;
4205        let off_cpbi = (header_size + prog_map_size) as u16;
4206        let off_cpos = (header_size + prog_map_size + cpbi_size) as u16;
4207        pgc_blob[0x00E6..0x00E8].copy_from_slice(&off_pmap.to_be_bytes());
4208        pgc_blob[0x00E8..0x00EA].copy_from_slice(&off_cpbi.to_be_bytes());
4209        pgc_blob[0x00EA..0x00EC].copy_from_slice(&off_cpos.to_be_bytes());
4210        pgc_blob[header_size] = 1;
4211        pgc_blob[header_size + 1] = 3;
4212        pgc_blob[header_size + 2] = 5;
4213        for (i, c) in cells.iter().enumerate() {
4214            let base = header_size + prog_map_size + i * 24;
4215            pgc_blob[base + 4..base + 8].copy_from_slice(&[0, 1, 0, 0xE0]);
4216            pgc_blob[base + 8..base + 12].copy_from_slice(&c.first_vobu_start_sector.to_be_bytes());
4217            pgc_blob[base + 12..base + 16].copy_from_slice(&c.first_ilvu_end_sector.to_be_bytes());
4218            pgc_blob[base + 16..base + 20].copy_from_slice(&c.last_vobu_start_sector.to_be_bytes());
4219            pgc_blob[base + 20..base + 24].copy_from_slice(&c.last_vobu_end_sector.to_be_bytes());
4220        }
4221        for (i, p) in positions.iter().enumerate() {
4222            let base = header_size + prog_map_size + cpbi_size + i * 4;
4223            pgc_blob[base..base + 2].copy_from_slice(&p.vob_id.to_be_bytes());
4224            pgc_blob[base + 3] = p.cell_id;
4225        }
4226        let srp_size = 8usize;
4227        let body_off = 8 + srp_size;
4228        let pgci_total = body_off + pgc_blob.len();
4229        let mut pgci = vec![0u8; pgci_total];
4230        pgci[0..2].copy_from_slice(&1u16.to_be_bytes());
4231        pgci[4..8].copy_from_slice(&((pgci_total - 1) as u32).to_be_bytes());
4232        pgci[12..16].copy_from_slice(&(body_off as u32).to_be_bytes());
4233        pgci[body_off..body_off + pgc_blob.len()].copy_from_slice(&pgc_blob);
4234        img[2 * DVD_SECTOR..2 * DVD_SECTOR + pgci.len()].copy_from_slice(&pgci);
4235
4236        // PTT_SRPT
4237        let n_titles = 1usize;
4238        let n_chaps = 3usize;
4239        let header_sz = 8 + n_titles * 4;
4240        let title_body = n_chaps * 4;
4241        let total = header_sz + title_body;
4242        let mut ptt = vec![0u8; total];
4243        ptt[0..2].copy_from_slice(&(n_titles as u16).to_be_bytes());
4244        ptt[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes());
4245        ptt[8..12].copy_from_slice(&(header_sz as u32).to_be_bytes());
4246        for ci in 0..n_chaps {
4247            let base = header_sz + ci * 4;
4248            ptt[base..base + 2].copy_from_slice(&1u16.to_be_bytes());
4249            ptt[base + 2..base + 4].copy_from_slice(&((ci + 1) as u16).to_be_bytes());
4250        }
4251        img[DVD_SECTOR..DVD_SECTOR + ptt.len()].copy_from_slice(&ptt);
4252
4253        // C_ADT
4254        let cadt_rows: Vec<(u16, u8, u32, u32)> = (0..5)
4255            .map(|i| {
4256                (
4257                    1u16,
4258                    (i + 1) as u8,
4259                    1000 + i as u32 * 1000,
4260                    1999 + i as u32 * 1000,
4261                )
4262            })
4263            .collect();
4264        let cadt = build_c_adt(&cadt_rows);
4265        img[3 * DVD_SECTOR..3 * DVD_SECTOR + cadt.len()].copy_from_slice(&cadt);
4266
4267        // VTS_VOBU_ADMAP at sector 4 — 4 VOBUs at VOB-relative
4268        // sectors 0, 250, 600, 1100.
4269        let admap = build_vobu_admap(&[0, 250, 600, 1100]);
4270        img[4 * DVD_SECTOR..4 * DVD_SECTOR + admap.len()].copy_from_slice(&admap);
4271
4272        // VTS_TMAPTI at sector 5 — one PGC, 4-second steps, three
4273        // entries pointing into the VOBU sector list.
4274        let tmap = build_tmap(4, &[(0, false), (250, false), (600, false)]);
4275        let tmapti = build_tmapti(&[tmap]);
4276        img[5 * DVD_SECTOR..5 * DVD_SECTOR + tmapti.len()].copy_from_slice(&tmapti);
4277
4278        img
4279    }
4280
4281    #[test]
4282    fn composite_vts_materialises_admap_and_tmap() {
4283        let img = make_composite_vts_with_admap_and_tmap();
4284        let vts = VtsIfo::parse(&img, 1).unwrap();
4285        let admap = vts.vobu_admap.as_ref().expect("VOBU_ADMAP materialised");
4286        assert_eq!(admap.vobu_count(), 4);
4287        assert_eq!(admap.vobu_start_sector(1), Some(0));
4288        assert_eq!(admap.vobu_start_sector(4), Some(1100));
4289        // Sector 700 falls inside VOBU 3's range [600, 1100).
4290        assert_eq!(admap.vobu_containing(700), Some(3));
4291
4292        let tmapti = vts.time_map.as_ref().expect("VTS_TMAPTI materialised");
4293        assert_eq!(tmapti.number_of_pgcs, 1);
4294        let pgc_map = tmapti.get(1).unwrap();
4295        assert_eq!(pgc_map.time_unit, 4);
4296        assert_eq!(pgc_map.entries.len(), 3);
4297
4298        // Time-based seek: 5 seconds in → step 2 (covers [4, 8)) →
4299        // VOBU at VOB-relative sector 250.
4300        assert_eq!(vts.vobu_sector_at_pgc_time(1, 5), Some(250));
4301        // 0 seconds → step 1 → sector 0.
4302        assert_eq!(vts.vobu_sector_at_pgc_time(1, 0), Some(0));
4303        // 9 seconds → step 3 → sector 600.
4304        assert_eq!(vts.vobu_sector_at_pgc_time(1, 9), Some(600));
4305        // Out-of-range PGCN → `None`.
4306        assert_eq!(vts.vobu_sector_at_pgc_time(2, 0), None);
4307    }
4308
4309    // -------------------------------------------------------------
4310    // VTSI_MAT stream-attribute extension
4311    // -------------------------------------------------------------
4312
4313    /// Build a single 8-byte audio-attribute slot covering the
4314    /// fields we exercise in the typed parse.
4315    #[allow(clippy::too_many_arguments)]
4316    fn pack_audio_attr(
4317        coding: u8,
4318        mc_ext: bool,
4319        lang_type: u8,
4320        app_mode: u8,
4321        quant: u8,
4322        sample_rate: u8,
4323        chans_minus_one: u8,
4324        lang: [u8; 2],
4325        code_ext: u8,
4326        app_info: u8,
4327    ) -> [u8; 8] {
4328        let mut b = [0u8; 8];
4329        b[0] = ((coding & 0b111) << 5)
4330            | (if mc_ext { 0b1_0000 } else { 0 })
4331            | ((lang_type & 0b11) << 2)
4332            | (app_mode & 0b11);
4333        b[1] = ((quant & 0b11) << 6) | ((sample_rate & 0b11) << 4) | (chans_minus_one & 0b111);
4334        b[2] = lang[0];
4335        b[3] = lang[1];
4336        b[5] = code_ext;
4337        b[7] = app_info;
4338        b
4339    }
4340
4341    #[allow(clippy::too_many_arguments)]
4342    fn pack_video_attr(
4343        coding: u8,
4344        standard: u8,
4345        aspect: u8,
4346        pan_scan_disallowed: bool,
4347        letterbox_disallowed: bool,
4348        cc1: bool,
4349        cc2: bool,
4350        resolution: u8,
4351        letterbox_src: bool,
4352        film_pal: bool,
4353    ) -> [u8; 2] {
4354        let mut b = [0u8; 2];
4355        b[0] = ((coding & 0b11) << 6)
4356            | ((standard & 0b11) << 4)
4357            | ((aspect & 0b11) << 2)
4358            | (if pan_scan_disallowed { 0b10 } else { 0 })
4359            | (if letterbox_disallowed { 0b01 } else { 0 });
4360        b[1] = (if cc1 { 0b1000_0000 } else { 0 })
4361            | (if cc2 { 0b0100_0000 } else { 0 })
4362            | ((resolution & 0b111) << 3)
4363            | (if letterbox_src { 0b0000_0100 } else { 0 })
4364            | (if film_pal { 0b0000_0001 } else { 0 });
4365        b
4366    }
4367
4368    fn pack_subp_attr(coding: u8, lang_type: u8, lang: [u8; 2], code_ext: u8) -> [u8; 6] {
4369        let mut b = [0u8; 6];
4370        b[0] = ((coding & 0b111) << 5) | (lang_type & 0b11);
4371        b[2] = lang[0];
4372        b[3] = lang[1];
4373        b[5] = code_ext;
4374        b
4375    }
4376
4377    #[test]
4378    fn video_attributes_mpeg2_pal_16x9_full_d1() {
4379        let raw = pack_video_attr(1, 1, 3, false, false, false, false, 0, false, true);
4380        let v = VideoAttributes::parse(&raw);
4381        assert_eq!(v.coding_mode, VideoCodingMode::Mpeg2);
4382        assert_eq!(v.standard, VideoStandard::Pal);
4383        assert_eq!(v.aspect_ratio, VideoAspectRatio::Ratio16x9);
4384        assert_eq!(v.resolution, VideoResolution::FullD1);
4385        assert_eq!(
4386            v.resolution.dimensions(VideoStandard::Pal),
4387            Some((720, 576))
4388        );
4389        assert!(v.film_source_pal);
4390        assert!(!v.line21_field1_cc);
4391    }
4392
4393    #[test]
4394    fn video_attributes_mpeg2_ntsc_4x3_sif_with_cc() {
4395        let raw = pack_video_attr(1, 0, 0, true, false, true, true, 3, true, false);
4396        let v = VideoAttributes::parse(&raw);
4397        assert_eq!(v.coding_mode, VideoCodingMode::Mpeg2);
4398        assert_eq!(v.standard, VideoStandard::Ntsc);
4399        assert_eq!(v.aspect_ratio, VideoAspectRatio::Ratio4x3);
4400        assert_eq!(v.resolution, VideoResolution::Sif);
4401        assert_eq!(
4402            v.resolution.dimensions(VideoStandard::Ntsc),
4403            Some((352, 240))
4404        );
4405        assert!(v.pan_scan_disallowed);
4406        assert!(v.line21_field1_cc);
4407        assert!(v.line21_field2_cc);
4408        assert!(v.letterboxed_source);
4409    }
4410
4411    #[test]
4412    fn video_attributes_reserved_aspect_and_resolution() {
4413        let raw = pack_video_attr(1, 0, 1, false, false, false, false, 5, false, false);
4414        let v = VideoAttributes::parse(&raw);
4415        assert_eq!(v.aspect_ratio, VideoAspectRatio::Reserved(1));
4416        assert_eq!(v.resolution, VideoResolution::Reserved(5));
4417        assert_eq!(v.resolution.dimensions(VideoStandard::Ntsc), None);
4418    }
4419
4420    #[test]
4421    fn audio_attributes_ac3_stereo_english() {
4422        // coding=0 (AC3), mc_ext=false, lang_type=1 (ISO), app=0 (unspec),
4423        // quant=0, sample=0 (48 kHz), chans-1=1 (stereo).
4424        let raw = pack_audio_attr(0, false, 1, 0, 0, 0, 1, *b"en", 0, 0);
4425        let a = AudioAttributes::parse(&raw);
4426        assert_eq!(a.coding_mode, AudioCodingMode::Ac3);
4427        assert!(!a.multichannel_extension_present);
4428        assert_eq!(a.language_type, AudioLanguageType::Iso639);
4429        assert_eq!(a.application_mode, AudioApplicationMode::Unspecified);
4430        assert_eq!(a.channel_count, 2);
4431        assert_eq!(a.sample_rate_hz(), Some(48_000));
4432        assert_eq!(&a.language_code, b"en");
4433        assert!(!a.dolby_surround_suitable());
4434    }
4435
4436    #[test]
4437    fn audio_attributes_lpcm_24bit_six_channel() {
4438        // coding=4 (LPCM), quant=2 (24bps), chans-1=5 (6 channels).
4439        let raw = pack_audio_attr(4, false, 0, 0, 2, 0, 5, [0, 0], 0, 0);
4440        let a = AudioAttributes::parse(&raw);
4441        assert_eq!(a.coding_mode, AudioCodingMode::Lpcm);
4442        assert_eq!(a.quantization, AudioQuantizationDrc::Lpcm24);
4443        assert_eq!(a.channel_count, 6);
4444    }
4445
4446    #[test]
4447    fn audio_attributes_mpeg2_drc_flag() {
4448        let raw = pack_audio_attr(3, false, 0, 0, 1, 0, 1, [0, 0], 0, 0);
4449        let a = AudioAttributes::parse(&raw);
4450        assert_eq!(a.coding_mode, AudioCodingMode::Mpeg2Ext);
4451        assert_eq!(a.quantization, AudioQuantizationDrc::Drc);
4452    }
4453
4454    #[test]
4455    fn audio_attributes_surround_dolby_suitable_bit() {
4456        // surround app_mode + byte 7 bit 3 = Dolby-Surround-suitable.
4457        let raw = pack_audio_attr(0, false, 0, 2, 0, 0, 1, [0, 0], 0, 0b0000_1000);
4458        let a = AudioAttributes::parse(&raw);
4459        assert_eq!(a.application_mode, AudioApplicationMode::Surround);
4460        assert!(a.dolby_surround_suitable());
4461    }
4462
4463    #[test]
4464    fn audio_attributes_karaoke_channel_assignment_3_0() {
4465        // karaoke app_mode + byte 7 bits 6..4 = 3 (= 3/0 L,M,R) + bit 1 set (MC intro).
4466        let raw = pack_audio_attr(0, true, 0, 1, 0, 0, 2, [0, 0], 0, 0b0011_0010);
4467        let a = AudioAttributes::parse(&raw);
4468        assert_eq!(a.application_mode, AudioApplicationMode::Karaoke);
4469        assert!(a.multichannel_extension_present);
4470        assert_eq!(a.karaoke_channel_assignment(), Some(3));
4471        assert_eq!(a.karaoke_mc_intro_present(), Some(true));
4472        assert_eq!(a.karaoke_duet(), Some(false));
4473    }
4474
4475    #[test]
4476    fn subpicture_attributes_2bit_rle_japanese() {
4477        let raw = pack_subp_attr(0, 1, *b"ja", 0);
4478        let s = SubpictureAttributes::parse(&raw);
4479        assert_eq!(s.coding_mode, SubpictureCodingMode::Rle2Bit);
4480        assert_eq!(s.language_type, SubpictureLanguageType::Iso639);
4481        assert_eq!(&s.language_code, b"ja");
4482    }
4483
4484    #[test]
4485    fn mc_extension_entry_decodes_per_channel_flags() {
4486        let raw = [
4487            0b0000_0001, // ACH0 guide melody
4488            0b0000_0000,
4489            0b0000_1010, // ACH2 GV1 + GM1
4490            0b0000_0101, // ACH3 GV2 + SE_A
4491            0b0000_1001, // ACH4 GV1 + SE_B
4492            0,
4493            0,
4494            0,
4495        ];
4496        let m = McExtensionEntry::parse(&raw);
4497        assert!(m.ach0_guide_melody);
4498        assert!(!m.ach1_guide_melody);
4499        assert!(m.ach2_guide_vocal_1);
4500        assert!(m.ach2_guide_melody_1);
4501        assert!(m.ach3_guide_vocal_2);
4502        assert!(m.ach3_sound_effect_a);
4503        assert!(m.ach4_guide_vocal_1);
4504        assert!(m.ach4_sound_effect_b);
4505        assert!(!m.ach4_guide_melody_b);
4506    }
4507
4508    /// Build a full 0x03D8-byte VTSI_MAT carrying the menu + title
4509    /// attribute extension blocks. We populate enough fields to
4510    /// exercise the typed decoders end-to-end.
4511    fn build_vtsi_mat_with_attrs() -> Vec<u8> {
4512        let mut b = vec![0u8; 0x03D8];
4513        b[0..12].copy_from_slice(VTS_MAGIC);
4514        b[0x0080..0x0084].copy_from_slice(&(0x03D7u32).to_be_bytes());
4515        // Sector pointers we don't exercise stay zero.
4516
4517        // Menu block at 0x0100..0x015C — MPEG-2 PAL 4:3 full-D1,
4518        // one MPEG-1 stereo audio stream, one Japanese sub-picture.
4519        let v_menu = pack_video_attr(1, 1, 0, false, false, false, false, 0, false, false);
4520        b[0x0100..0x0102].copy_from_slice(&v_menu);
4521        b[0x0102..0x0104].copy_from_slice(&1u16.to_be_bytes());
4522        let a_menu = pack_audio_attr(2, false, 1, 0, 0, 0, 1, *b"en", 1, 0);
4523        b[0x0104..0x010C].copy_from_slice(&a_menu);
4524        b[0x0154..0x0156].copy_from_slice(&1u16.to_be_bytes());
4525        let s_menu = pack_subp_attr(0, 1, *b"ja", 0);
4526        b[0x0156..0x015C].copy_from_slice(&s_menu);
4527
4528        // Title block at 0x0200..0x03D8 — MPEG-2 NTSC 16:9 full-D1,
4529        // two AC-3 streams (en 5.1, fr stereo) + two sub-picture
4530        // streams (en + de).
4531        let v_title = pack_video_attr(1, 0, 3, false, false, false, false, 0, false, false);
4532        b[0x0200..0x0202].copy_from_slice(&v_title);
4533        b[0x0202..0x0204].copy_from_slice(&2u16.to_be_bytes());
4534        let a0 = pack_audio_attr(0, false, 1, 0, 0, 0, 5, *b"en", 1, 0);
4535        let a1 = pack_audio_attr(0, false, 1, 0, 0, 0, 1, *b"fr", 1, 0);
4536        b[0x0204..0x020C].copy_from_slice(&a0);
4537        b[0x020C..0x0214].copy_from_slice(&a1);
4538        b[0x0254..0x0256].copy_from_slice(&2u16.to_be_bytes());
4539        let s0 = pack_subp_attr(0, 1, *b"en", 0);
4540        let s1 = pack_subp_attr(0, 1, *b"de", 0);
4541        b[0x0256..0x025C].copy_from_slice(&s0);
4542        b[0x025C..0x0262].copy_from_slice(&s1);
4543
4544        // MC extension slot at 0x0318 — leave 24 zeroed entries.
4545        b
4546    }
4547
4548    #[test]
4549    fn vtsi_mat_decodes_menu_and_title_attribute_blocks() {
4550        let buf = build_vtsi_mat_with_attrs();
4551        let mat = VtsiMat::parse(&buf).unwrap();
4552
4553        let menu_v = mat.menu_attributes.video.unwrap();
4554        assert_eq!(menu_v.standard, VideoStandard::Pal);
4555        assert_eq!(menu_v.aspect_ratio, VideoAspectRatio::Ratio4x3);
4556        assert_eq!(mat.menu_attributes.audio_streams.len(), 1);
4557        assert_eq!(
4558            mat.menu_attributes.audio_streams[0].coding_mode,
4559            AudioCodingMode::Mpeg1
4560        );
4561        assert_eq!(mat.menu_attributes.subpicture_streams.len(), 1);
4562        assert_eq!(
4563            &mat.menu_attributes.subpicture_streams[0].language_code,
4564            b"ja"
4565        );
4566
4567        let title_v = mat.title_attributes.video.unwrap();
4568        assert_eq!(title_v.standard, VideoStandard::Ntsc);
4569        assert_eq!(title_v.aspect_ratio, VideoAspectRatio::Ratio16x9);
4570        assert_eq!(mat.title_attributes.audio_streams.len(), 2);
4571        assert_eq!(mat.title_attributes.audio_streams[0].channel_count, 6);
4572        assert_eq!(&mat.title_attributes.audio_streams[1].language_code, b"fr");
4573        assert_eq!(mat.title_attributes.subpicture_streams.len(), 2);
4574        assert_eq!(
4575            &mat.title_attributes.subpicture_streams[1].language_code,
4576            b"de"
4577        );
4578        assert_eq!(mat.title_attributes.multichannel_extension.len(), 24);
4579        assert!(!mat.title_attributes.multichannel_extension[0].ach0_guide_melody);
4580    }
4581
4582    #[test]
4583    fn vtsi_mat_short_buffer_leaves_attributes_partial() {
4584        // The 0x200-byte buffer used by the legacy roundtrip test
4585        // covers the menu block but not the title block.
4586        let buf = build_vtsi_mat(1, 2, 3, 42);
4587        let mat = VtsiMat::parse(&buf).unwrap();
4588        // Menu block fits entirely within 0x200, so its video field
4589        // is parsed (all-zero → MPEG-1 NTSC 4:3).
4590        let menu_v = mat.menu_attributes.video.unwrap();
4591        assert_eq!(menu_v.coding_mode, VideoCodingMode::Mpeg1);
4592        // Title block starts at 0x0200 — buffer ends before that.
4593        assert!(mat.title_attributes.video.is_none());
4594        assert!(mat.title_attributes.audio_streams.is_empty());
4595        assert!(mat.title_attributes.multichannel_extension.is_empty());
4596    }
4597
4598    // -------------------------------------------------------------
4599    // VMG_VTS_ATRT
4600    // -------------------------------------------------------------
4601
4602    /// Build a synthetic `VMG_VTS_ATRT` with `entries.len()` entries,
4603    /// each carrying the supplied `(vts_category, blob)` pair.
4604    fn build_vts_atrt(entries: &[(u32, Vec<u8>)]) -> Vec<u8> {
4605        // Header is 8 bytes + 4 bytes per offset entry.
4606        let header = 8 + 4 * entries.len();
4607        // Each entry body = 8-byte header + blob.
4608        let mut offsets = Vec::with_capacity(entries.len());
4609        let mut bodies = Vec::with_capacity(entries.len());
4610        let mut cursor = header;
4611        for (cat, blob) in entries {
4612            offsets.push(cursor as u32);
4613            let entry_total = 8 + blob.len();
4614            let entry_ea = (entry_total - 1) as u32;
4615            let mut e = Vec::with_capacity(entry_total);
4616            e.extend_from_slice(&entry_ea.to_be_bytes());
4617            e.extend_from_slice(&cat.to_be_bytes());
4618            e.extend_from_slice(blob);
4619            bodies.push(e);
4620            cursor += entry_total;
4621        }
4622        let total = cursor;
4623        let mut out = vec![0u8; total];
4624        out[0..2].copy_from_slice(&(entries.len() as u16).to_be_bytes());
4625        // bytes 2..4 reserved
4626        out[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes());
4627        for (i, off) in offsets.iter().enumerate() {
4628            out[8 + i * 4..8 + (i + 1) * 4].copy_from_slice(&off.to_be_bytes());
4629        }
4630        for (i, body) in bodies.iter().enumerate() {
4631            let start = offsets[i] as usize;
4632            out[start..start + body.len()].copy_from_slice(body);
4633        }
4634        out
4635    }
4636
4637    #[test]
4638    fn vts_atrt_walks_two_entries() {
4639        let blob_a = (0..0x300u32).map(|i| (i & 0xFF) as u8).collect::<Vec<_>>();
4640        let blob_b = vec![0x55u8; 0x300];
4641        let buf = build_vts_atrt(&[(0, blob_a.clone()), (1, blob_b.clone())]);
4642        let atrt = VmgVtsAtrt::parse(&buf).unwrap();
4643        assert_eq!(atrt.number_of_title_sets, 2);
4644        assert_eq!(atrt.entries.len(), 2);
4645
4646        let e1 = atrt.entry(1).unwrap();
4647        assert_eq!(e1.vts_number, 1);
4648        assert_eq!(e1.vts_category, 0);
4649        assert_eq!(e1.attributes_blob, blob_a);
4650
4651        let e2 = atrt.entry(2).unwrap();
4652        assert_eq!(e2.vts_number, 2);
4653        assert_eq!(e2.vts_category, 1); // Karaoke flag
4654        assert_eq!(e2.attributes_blob, blob_b);
4655
4656        assert!(atrt.entry(0).is_none());
4657        assert!(atrt.entry(3).is_none());
4658    }
4659
4660    #[test]
4661    fn vts_atrt_rejects_short_header() {
4662        let buf = vec![0u8; 4];
4663        assert!(VmgVtsAtrt::parse(&buf).is_err());
4664    }
4665
4666    #[test]
4667    fn vts_atrt_rejects_offset_list_past_end() {
4668        let mut buf = vec![0u8; 8];
4669        buf[0..2].copy_from_slice(&5u16.to_be_bytes()); // claims 5 entries
4670                                                        // but the offset list (20 bytes) doesn't fit in the 8-byte buffer
4671        assert!(VmgVtsAtrt::parse(&buf).is_err());
4672    }
4673
4674    #[test]
4675    fn vts_atrt_rejects_entry_ea_overlapping_next_entry() {
4676        // Build a valid 2-entry table, then corrupt entry 1's EA so it
4677        // claims more bytes than entry 2's offset allows.
4678        let blob = vec![0xAAu8; 0x100];
4679        let mut buf = build_vts_atrt(&[(0, blob.clone()), (0, blob.clone())]);
4680        // Entry 1 is at offset = read_u32(buf, 8). Bloat its EA field
4681        // (entry-local offset 0..4) so the entry would extend past
4682        // entry 2's start.
4683        let e1_off = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]) as usize;
4684        buf[e1_off..e1_off + 4].copy_from_slice(&0xFFFFu32.to_be_bytes());
4685        assert!(VmgVtsAtrt::parse(&buf).is_err());
4686    }
4687
4688    // -------------------------------------------------------------
4689    // VMG_PTL_MAIT
4690    // -------------------------------------------------------------
4691
4692    /// Build a synthetic `VMG_PTL_MAIT` with `entries.len()` countries
4693    /// and `nts` title sets. Each entry's `masks` are passed as
4694    /// `[level1..level8]` arrays of `nts + 1` u16s — they're stored on
4695    /// disc in descending level order (level 8 first).
4696    fn build_ptl_mait(
4697        country_codes: &[u16],
4698        nts: u16,
4699        masks_per_country: &[[Vec<u16>; 8]],
4700    ) -> Vec<u8> {
4701        assert_eq!(country_codes.len(), masks_per_country.len());
4702        let header_len = 8 + 8 * country_codes.len();
4703        let level_block_bytes = (usize::from(nts) + 1) * 2;
4704        let body_bytes = level_block_bytes * 8;
4705        let total = header_len + body_bytes * country_codes.len();
4706
4707        let mut buf = vec![0u8; total];
4708        buf[0..2].copy_from_slice(&(country_codes.len() as u16).to_be_bytes());
4709        buf[2..4].copy_from_slice(&nts.to_be_bytes());
4710        buf[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes());
4711
4712        for (i, (&cc, masks)) in country_codes
4713            .iter()
4714            .zip(masks_per_country.iter())
4715            .enumerate()
4716        {
4717            let body_offset = header_len + i * body_bytes;
4718            // Entry: cc(u16) | reserved(u16) | offset(u16) | reserved(u16)
4719            let entry_base = 8 + i * 8;
4720            buf[entry_base..entry_base + 2].copy_from_slice(&cc.to_be_bytes());
4721            // bytes 2..4 reserved
4722            buf[entry_base + 4..entry_base + 6]
4723                .copy_from_slice(&(body_offset as u16).to_be_bytes());
4724            // bytes 6..8 reserved
4725            // Body: 8 stacked blocks, level 8 first.
4726            for storage_idx in 0..8usize {
4727                let level_value = 8 - storage_idx; // 8..=1
4728                let row = &masks[level_value - 1]; // user passed ascending
4729                assert_eq!(row.len(), usize::from(nts) + 1);
4730                let block_start = body_offset + storage_idx * level_block_bytes;
4731                for (slot, &m) in row.iter().enumerate() {
4732                    buf[block_start + slot * 2..block_start + slot * 2 + 2]
4733                        .copy_from_slice(&m.to_be_bytes());
4734                }
4735            }
4736        }
4737        buf
4738    }
4739
4740    #[test]
4741    fn ptl_mait_walks_two_countries() {
4742        // 2 countries × nts=2 (so masks_per_level = 3: VMG + 2 VTSs).
4743        let mut country_a_masks: [Vec<u16>; 8] = Default::default();
4744        let mut country_b_masks: [Vec<u16>; 8] = Default::default();
4745        for lvl_idx in 0..8 {
4746            // level 1..=8 stored ascending in the user-facing layout.
4747            let lvl = (lvl_idx + 1) as u16;
4748            country_a_masks[lvl_idx] = vec![lvl, lvl + 0x10, lvl + 0x20];
4749            country_b_masks[lvl_idx] =
4750                vec![0xF000 | lvl, 0xF000 | (lvl + 0x10), 0xF000 | (lvl + 0x20)];
4751        }
4752        // Country code 'us' = 0x7553; 'jp' = 0x6A70.
4753        let buf = build_ptl_mait(
4754            &[0x7553, 0x6A70],
4755            2,
4756            &[country_a_masks.clone(), country_b_masks.clone()],
4757        );
4758
4759        let pm = VmgPtlMait::parse(&buf).unwrap();
4760        assert_eq!(pm.number_of_countries, 2);
4761        assert_eq!(pm.number_of_title_sets, 2);
4762        assert_eq!(pm.entries.len(), 2);
4763
4764        let us = pm.country(0x7553).expect("US country present");
4765        // Level 1 mask for VMG (title_set=0) was 1.
4766        assert_eq!(us.mask(1, 0), Some(1));
4767        // Level 1 mask for VTS 1 was 0x11.
4768        assert_eq!(us.mask(1, 1), Some(0x11));
4769        // Level 1 mask for VTS 2 was 0x21.
4770        assert_eq!(us.mask(1, 2), Some(0x21));
4771        // Level 8 mask for VTS 2 was 8 + 0x20 = 0x28.
4772        assert_eq!(us.mask(8, 2), Some(0x28));
4773
4774        let jp = pm.country(0x6A70).unwrap();
4775        assert_eq!(jp.mask(1, 0), Some(0xF001));
4776        assert_eq!(jp.mask(8, 2), Some(0xF028));
4777
4778        assert!(pm.country(0x4242).is_none());
4779        // Out-of-range parental level.
4780        assert_eq!(us.mask(0, 0), None);
4781        assert_eq!(us.mask(9, 0), None);
4782        // Out-of-range title-set index.
4783        assert_eq!(us.mask(1, 3), None);
4784    }
4785
4786    #[test]
4787    fn ptl_mait_zero_countries_decodes_empty() {
4788        let buf = build_ptl_mait(&[], 2, &[]);
4789        let pm = VmgPtlMait::parse(&buf).unwrap();
4790        assert_eq!(pm.number_of_countries, 0);
4791        assert!(pm.entries.is_empty());
4792    }
4793
4794    #[test]
4795    fn ptl_mait_rejects_short_header() {
4796        let buf = vec![0u8; 4];
4797        assert!(VmgPtlMait::parse(&buf).is_err());
4798    }
4799
4800    #[test]
4801    fn ptl_mait_rejects_country_list_past_end() {
4802        let mut buf = vec![0u8; 8];
4803        buf[0..2].copy_from_slice(&5u16.to_be_bytes()); // 5 countries
4804        buf[2..4].copy_from_slice(&2u16.to_be_bytes());
4805        // Truncated — no room for 5 × 8-byte entries after the header.
4806        assert!(VmgPtlMait::parse(&buf).is_err());
4807    }
4808
4809    #[test]
4810    fn ptl_mait_rejects_body_offset_past_buffer() {
4811        // 1 country, nts=2 → body needs 8 × 6 = 48 bytes. Header
4812        // (8) + entry (8) = 16. Total valid buffer would be 16 + 48 = 64
4813        // bytes; truncate to 16 to trip the bound check.
4814        let mut buf = vec![0u8; 16];
4815        buf[0..2].copy_from_slice(&1u16.to_be_bytes());
4816        buf[2..4].copy_from_slice(&2u16.to_be_bytes());
4817        buf[4..8].copy_from_slice(&63u32.to_be_bytes());
4818        buf[8..10].copy_from_slice(&0x7553u16.to_be_bytes());
4819        buf[12..14].copy_from_slice(&16u16.to_be_bytes()); // body starts past buf end
4820        assert!(VmgPtlMait::parse(&buf).is_err());
4821    }
4822
4823    // ----------------------------------------------------------------
4824    // VMGM_PGCI_UT / VTSM_PGCI_UT — Menu PGCI Unit Table
4825    // ----------------------------------------------------------------
4826
4827    /// Minimal PGC body (236 bytes) — all sub-table offsets zero so
4828    /// `Pgc::parse` walks the header alone.
4829    fn empty_pgc_body() -> Vec<u8> {
4830        vec![0u8; 0xEC]
4831    }
4832
4833    /// Build a synthetic PGCI_UT buffer from `(language_code,
4834    /// language_code_ext, menu_existence, pgc_categories)` tuples.
4835    /// Each language unit gets one PGC per `pgc_categories` entry,
4836    /// each PGC carries an empty 236-byte body.
4837    fn build_pgci_ut(units: &[(u16, u8, u8, Vec<u32>)]) -> Vec<u8> {
4838        let header_len = 8 + 8 * units.len();
4839        // Layout: outer header, outer SRP list, then back-to-back LUs.
4840        // Each LU has its own (8 + n_pgcs × 8) header + (n_pgcs × 236)
4841        // PGC bodies.
4842        let lu_lens: Vec<usize> = units
4843            .iter()
4844            .map(|(_, _, _, pgcs)| 8 + pgcs.len() * 8 + pgcs.len() * 0xEC)
4845            .collect();
4846        let total: usize = header_len + lu_lens.iter().sum::<usize>();
4847        let mut buf = vec![0u8; total];
4848        // Outer header: num LUs + end_address.
4849        buf[0..2].copy_from_slice(&(units.len() as u16).to_be_bytes());
4850        buf[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes());
4851
4852        let mut lu_offset = header_len;
4853        for (i, ((lang, ext, exist, pgcs), &lu_len)) in units.iter().zip(lu_lens.iter()).enumerate()
4854        {
4855            // Outer SRP entry: lang(u16) | ext(u8) | exist(u8) | offset(u32)
4856            let srp_base = 8 + i * 8;
4857            buf[srp_base..srp_base + 2].copy_from_slice(&lang.to_be_bytes());
4858            buf[srp_base + 2] = *ext;
4859            buf[srp_base + 3] = *exist;
4860            buf[srp_base + 4..srp_base + 8].copy_from_slice(&(lu_offset as u32).to_be_bytes());
4861
4862            // LU header at lu_offset: n_pgcs + end_address relative to LU.
4863            buf[lu_offset..lu_offset + 2].copy_from_slice(&(pgcs.len() as u16).to_be_bytes());
4864            buf[lu_offset + 4..lu_offset + 8].copy_from_slice(&((lu_len - 1) as u32).to_be_bytes());
4865
4866            // Inner SRP entries + back-to-back PGC bodies.
4867            let inner_srp_end = 8 + pgcs.len() * 8;
4868            for (j, &cat) in pgcs.iter().enumerate() {
4869                let inner_base = lu_offset + 8 + j * 8;
4870                buf[inner_base..inner_base + 4].copy_from_slice(&cat.to_be_bytes());
4871                let pgc_offset_in_lu = (inner_srp_end + j * 0xEC) as u32;
4872                buf[inner_base + 4..inner_base + 8]
4873                    .copy_from_slice(&pgc_offset_in_lu.to_be_bytes());
4874
4875                // Body is all zero from the vec init — that matches
4876                // empty_pgc_body() and parses fine.
4877                let pgc_global = lu_offset + pgc_offset_in_lu as usize;
4878                let empty = empty_pgc_body();
4879                buf[pgc_global..pgc_global + 0xEC].copy_from_slice(&empty);
4880            }
4881
4882            lu_offset += lu_len;
4883        }
4884        buf
4885    }
4886
4887    #[test]
4888    fn pgci_ut_walks_two_language_units() {
4889        // EN + JP, each with 2 PGCs. Categories encode entry-PGC flag
4890        // (bit 31) + menu-type nibble (low 4 bits of byte 0).
4891        // EN: PGC1 = entry-Root (0x83_00_00_00), PGC2 = non-entry (0x00_…).
4892        // JP: PGC1 = entry-Title (0x82_00_00_00), PGC2 = non-entry.
4893        let units = vec![
4894            (
4895                0x656E, // "en"
4896                0,
4897                menu_existence::ROOT | menu_existence::AUDIO,
4898                vec![0x83_00_00_00, 0x00_00_00_00],
4899            ),
4900            (
4901                0x6A70, // "jp"
4902                0,
4903                menu_existence::ROOT,
4904                vec![0x82_00_00_00, 0x00_00_00_00],
4905            ),
4906        ];
4907        let buf = build_pgci_ut(&units);
4908        let table = PgciUt::parse(&buf).unwrap();
4909
4910        assert_eq!(table.number_of_language_units, 2);
4911        assert_eq!(table.srp.len(), 2);
4912        assert_eq!(table.language_units.len(), 2);
4913
4914        // First LU: English, root + audio menus.
4915        let en_srp = &table.srp[0];
4916        assert_eq!(en_srp.language_code, 0x656E);
4917        assert!(en_srp.has_root_menu());
4918        assert!(en_srp.has_audio_menu());
4919        assert!(!en_srp.has_subpicture_menu());
4920        assert!(!en_srp.has_angle_menu());
4921        assert!(!en_srp.has_ptt_menu());
4922
4923        let en_lu = &table.language_units[0];
4924        assert_eq!(en_lu.number_of_pgcs, 2);
4925        assert_eq!(en_lu.srp.len(), 2);
4926        assert_eq!(en_lu.pgcs.len(), 2);
4927        assert!(en_lu.srp[0].is_entry_pgc());
4928        assert_eq!(en_lu.srp[0].menu_type(), MenuType::Root);
4929        assert!(!en_lu.srp[1].is_entry_pgc());
4930
4931        // Second LU: Japanese, title-menu entry.
4932        let jp_srp = &table.srp[1];
4933        assert_eq!(jp_srp.language_code, 0x6A70);
4934        let jp_lu = &table.language_units[1];
4935        assert_eq!(jp_lu.srp[0].menu_type(), MenuType::Title);
4936
4937        // language_unit() lookup round-trips.
4938        assert_eq!(
4939            table.language_unit(0x656E).map(|lu| lu.number_of_pgcs),
4940            Some(2)
4941        );
4942        assert!(table.language_unit(0x4242).is_none());
4943    }
4944
4945    #[test]
4946    fn pgci_ut_zero_language_units_decodes_empty() {
4947        let buf = build_pgci_ut(&[]);
4948        let table = PgciUt::parse(&buf).unwrap();
4949        assert_eq!(table.number_of_language_units, 0);
4950        assert!(table.srp.is_empty());
4951        assert!(table.language_units.is_empty());
4952    }
4953
4954    #[test]
4955    fn pgci_ut_rejects_short_header() {
4956        let buf = vec![0u8; 4];
4957        assert!(PgciUt::parse(&buf).is_err());
4958    }
4959
4960    #[test]
4961    fn pgci_ut_rejects_srp_list_past_buffer() {
4962        // Claim 5 LUs but truncate after the header.
4963        let mut buf = vec![0u8; 8];
4964        buf[0..2].copy_from_slice(&5u16.to_be_bytes());
4965        assert!(PgciUt::parse(&buf).is_err());
4966    }
4967
4968    #[test]
4969    fn pgci_ut_rejects_lu_offset_zero() {
4970        // One LU with offset=0 — should be rejected per the in-range
4971        // check in `PgciUt::parse`.
4972        let mut buf = vec![0u8; 16];
4973        buf[0..2].copy_from_slice(&1u16.to_be_bytes());
4974        // outer SRP entry at offset 8: lang=0x656E, exist=0x80, off=0.
4975        buf[8..10].copy_from_slice(&0x656Eu16.to_be_bytes());
4976        buf[11] = menu_existence::ROOT;
4977        // offset bytes already zero.
4978        assert!(PgciUt::parse(&buf).is_err());
4979    }
4980
4981    #[test]
4982    fn pgci_ut_rejects_lu_offset_past_buffer() {
4983        // One LU whose offset points beyond the buffer end.
4984        let mut buf = vec![0u8; 16];
4985        buf[0..2].copy_from_slice(&1u16.to_be_bytes());
4986        buf[8..10].copy_from_slice(&0x656Eu16.to_be_bytes());
4987        buf[11] = menu_existence::ROOT;
4988        buf[12..16].copy_from_slice(&64u32.to_be_bytes()); // beyond 16-byte buf
4989        assert!(PgciUt::parse(&buf).is_err());
4990    }
4991
4992    #[test]
4993    fn pgci_lu_rejects_pgc_offset_past_buffer() {
4994        // A 16-byte LU body claiming 1 PGC whose offset points outside
4995        // the LU buffer.
4996        let mut buf = vec![0u8; 16];
4997        buf[0..2].copy_from_slice(&1u16.to_be_bytes());
4998        // Inner SRP at offset 8: category=0, offset=512 (way past 16).
4999        buf[12..16].copy_from_slice(&512u32.to_be_bytes());
5000        assert!(PgciLu::parse(&buf).is_err());
5001    }
5002
5003    #[test]
5004    fn pgci_lu_srp_decodes_parental_mask() {
5005        // Build a single-LU table with one PGC whose category dword
5006        // is 0x83_00_00_FF — entry-Root menu, parental mask 0x00FF.
5007        let units = vec![(0x656E, 0, menu_existence::ROOT, vec![0x83_00_00_FF_u32])];
5008        let buf = build_pgci_ut(&units);
5009        let table = PgciUt::parse(&buf).unwrap();
5010        let lu = &table.language_units[0];
5011        assert!(lu.srp[0].is_entry_pgc());
5012        assert_eq!(lu.srp[0].menu_type(), MenuType::Root);
5013        assert_eq!(lu.srp[0].parental_mask(), 0x00FF);
5014    }
5015
5016    #[test]
5017    fn menu_type_unknown_for_undefined_nibble() {
5018        // The spec defines 2..=7; 1 / 8..=15 fall through to Unknown.
5019        assert_eq!(MenuType::from_nibble(1), MenuType::Unknown(1));
5020        assert_eq!(MenuType::from_nibble(0), MenuType::Unknown(0));
5021        // High nibble is masked off — only low 4 bits matter.
5022        assert_eq!(MenuType::from_nibble(0xF3), MenuType::Root);
5023    }
5024}