Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL). The CSS content cipher and Stevenson title-key attack are attributed to their published cryptanalysis (not libdvdcss); all libaacs/libbluray/libdvdread/libdvdnav name references were dropped from comments while keeping the standard format/spec descriptions. Also bumps to 1.3.1.
409 lines
12 KiB
Rust
409 lines
12 KiB
Rust
//! DVD-Video VM command decoder.
|
|
//!
|
|
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
|
|
//! and PCI button info. Decoded per the DVD-Video VM instruction set and
|
|
//! verified against real discs.
|
|
//!
|
|
//! Bit model: the 8 bytes are a big-endian 64-bit word. `byte0` bits 7-5 are the
|
|
//! command **type**; for type 1, `byte0` bit 4 selects Link (0) vs Jump (1), and
|
|
//! `byte1` bits 3-0 are the sub-command. Compare predicates live in `byte1`
|
|
//! bits 6-4 with the operands in bytes 2-5.
|
|
//!
|
|
//! This module is pure decode + a register model — no I/O, no English (numeric
|
|
//! semantics only), matching libfreemkv conventions. The navigation *executor*
|
|
//! and IFO/PCI parsing build on top of this.
|
|
|
|
/// A decoded navigation instruction. Only the variants freemkv's start-point
|
|
/// resolver needs are modelled explicitly; everything else is [`Instr::Other`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Instr {
|
|
Nop,
|
|
/// Stop executing the current command list (resume cell playback).
|
|
Break,
|
|
/// Goto command line within the same list (1-based).
|
|
Goto {
|
|
line: u8,
|
|
},
|
|
/// Leave the current domain.
|
|
Exit,
|
|
/// Jump to a VMG title (1-based TT_SRPT index).
|
|
JumpTt {
|
|
ttn: u8,
|
|
},
|
|
/// Jump to a title within the current VTS (1-based VTS title index).
|
|
JumpVtsTt {
|
|
ttn: u8,
|
|
},
|
|
/// Jump to a part-of-title (chapter) within a VTS title.
|
|
JumpVtsPtt {
|
|
ttn: u8,
|
|
pttn: u16,
|
|
},
|
|
/// Jump to the First-Play PGC.
|
|
JumpSsFp,
|
|
/// Jump to a Video-Manager menu (`menu` = menu id).
|
|
JumpSsVmgm {
|
|
menu: u8,
|
|
},
|
|
/// Jump to a Video-Title-Set menu.
|
|
JumpSsVtsm {
|
|
vts: u8,
|
|
ttn: u8,
|
|
menu: u8,
|
|
},
|
|
/// Jump to a specific VMGM menu PGC.
|
|
JumpSsVmgmPgc {
|
|
pgcn: u16,
|
|
},
|
|
/// Call a sub-domain (raw retained; resume handled by the executor).
|
|
CallSs {
|
|
sub: u8,
|
|
},
|
|
/// Link to a PGC number within the current domain.
|
|
LinkPgcn {
|
|
pgcn: u16,
|
|
},
|
|
/// Link to a part-of-title within the current PGC's title.
|
|
LinkPttn {
|
|
pttn: u16,
|
|
},
|
|
/// Link to a program number within the current PGC (1-based).
|
|
LinkPgn {
|
|
pgn: u8,
|
|
},
|
|
/// Link to a cell number within the current PGC (1-based).
|
|
LinkCn {
|
|
cn: u8,
|
|
},
|
|
/// A link "subset" op (LinkTopCell/NextPG/RSM/…); `sub` is the raw code.
|
|
LinkSub {
|
|
sub: u8,
|
|
},
|
|
/// Set a GPRM. `op` is the set-op code (1=mov, 3=add, …); value is immediate
|
|
/// (`imm`) when `immediate`, else the contents of register `src`.
|
|
SetGprm {
|
|
reg: u8,
|
|
op: u8,
|
|
immediate: bool,
|
|
imm: u16,
|
|
src: u8,
|
|
},
|
|
/// Set a system parameter / unmodelled set — executor may ignore.
|
|
SetSystem,
|
|
/// Anything not individually modelled (kept as raw bytes).
|
|
Other([u8; 8]),
|
|
}
|
|
|
|
/// A compare predicate carried by a command (`byte1` bits 6-4). `None` = always.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Compare {
|
|
/// Compare op: 1=&,2===,3=!=,4=>=,5=>,6=<=,7=<.
|
|
pub op: u8,
|
|
/// Left register index (GPRM 0-15, SPRM 128+).
|
|
pub lhs_reg: u8,
|
|
/// Right side: immediate when `immediate`, else register `rhs_reg`.
|
|
pub immediate: bool,
|
|
pub imm: u16,
|
|
pub rhs_reg: u8,
|
|
}
|
|
|
|
/// A fully decoded command: its predicate (if any) and the instruction.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Command {
|
|
pub compare: Option<Compare>,
|
|
pub instr: Instr,
|
|
}
|
|
|
|
// Command types — `byte0` bits 7-5.
|
|
const TYPE_SPECIAL: u8 = 0;
|
|
const TYPE_LINK_JUMP: u8 = 1;
|
|
const TYPE_SET_SYSTEM: u8 = 2;
|
|
const TYPE_SET_GPRM: u8 = 3;
|
|
|
|
// Special (type 0) sub-commands — `byte1` bits 3-0.
|
|
const SP_GOTO: u8 = 1;
|
|
const SP_BREAK: u8 = 2;
|
|
|
|
// Jump/Call (type 1, direct=1) sub-commands.
|
|
const JP_EXIT: u8 = 1;
|
|
const JP_JUMP_TT: u8 = 2;
|
|
const JP_JUMP_VTS_TT: u8 = 3;
|
|
const JP_JUMP_VTS_PTT: u8 = 5;
|
|
const JP_JUMP_SS: u8 = 6;
|
|
const JP_CALL_SS: u8 = 8;
|
|
|
|
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
|
|
// the LinkSub form (the DVD-Video VM link instruction).
|
|
const LK_SUB: u8 = 1;
|
|
const LK_PGCN: u8 = 4;
|
|
const LK_PTTN: u8 = 5;
|
|
const LK_PGN: u8 = 6;
|
|
const LK_CN: u8 = 7;
|
|
|
|
// JumpSS sub-domain selector — `byte5` bits 7-6.
|
|
const SS_FP: u8 = 0;
|
|
const SS_VMGM_MENU: u8 = 1;
|
|
const SS_VTSM: u8 = 2;
|
|
|
|
// Operand field widths (spec-defined bit counts).
|
|
const MASK_TTN: u8 = 0x7F; // 7-bit title number
|
|
const MASK_PGN: u8 = 0x7F; // 7-bit program number
|
|
const MASK_LINKOP: u8 = 0x1F; // 5-bit link sub-op
|
|
const MASK_REG: u8 = 0x0F; // 4-bit GPRM index
|
|
const MASK_MENU: u8 = 0x0F; // 4-bit menu id
|
|
const MASK_PTTN: u16 = 0x03FF; // 10-bit part-of-title
|
|
const MASK_PGCN: u16 = 0x7FFF; // 15-bit PGC number
|
|
|
|
#[inline]
|
|
fn be16(b: &[u8; 8], o: usize) -> u16 {
|
|
((b[o] as u16) << 8) | b[o + 1] as u16
|
|
}
|
|
|
|
// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op
|
|
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
|
// operand *offsets* differ by command family.
|
|
//
|
|
// v1 (special + link): lhs reg = b[3]; rhs imm = bytes4-5 / rhs reg = b[4].
|
|
// v2 (jump + system-set): lhs reg = b[6]; rhs reg = b[7] (registers only).
|
|
// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[6].
|
|
fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
|
let op = (b[1] >> 4) & 7;
|
|
(op != 0).then(|| Compare {
|
|
op,
|
|
lhs_reg: b[3],
|
|
immediate: b[1] >> 7 != 0,
|
|
imm: be16(b, 4),
|
|
rhs_reg: b[4],
|
|
})
|
|
}
|
|
fn if_v2(b: &[u8; 8]) -> Option<Compare> {
|
|
let op = (b[1] >> 4) & 7;
|
|
(op != 0).then(|| Compare {
|
|
op,
|
|
lhs_reg: b[6],
|
|
immediate: false,
|
|
imm: 0,
|
|
rhs_reg: b[7],
|
|
})
|
|
}
|
|
fn if_v3(b: &[u8; 8]) -> Option<Compare> {
|
|
let op = (b[1] >> 4) & 7;
|
|
(op != 0).then(|| Compare {
|
|
op,
|
|
lhs_reg: b[2],
|
|
immediate: b[1] >> 7 != 0,
|
|
imm: be16(b, 6),
|
|
rhs_reg: b[6],
|
|
})
|
|
}
|
|
|
|
/// Decode an 8-byte VM command.
|
|
pub fn decode(b: &[u8; 8]) -> Command {
|
|
let typ = b[0] >> 5;
|
|
let direct = (b[0] >> 4) & 1;
|
|
let setop = b[0] & 0x0F;
|
|
let cmd = b[1] & 0x0F;
|
|
|
|
// Compare predicate, with the operand layout for this command family
|
|
// (the DVD-Video VM command type dispatch).
|
|
let compare = match (typ, direct) {
|
|
(TYPE_SPECIAL, _) => if_v1(b),
|
|
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
|
|
(TYPE_LINK_JUMP, 0) => if_v1(b), // link
|
|
(TYPE_SET_SYSTEM, _) => if_v2(b),
|
|
(TYPE_SET_GPRM, _) => if_v3(b),
|
|
_ => None, // 4/5/6 compound — not needed by the resolver
|
|
};
|
|
|
|
// JumpSS sub-domain selector lives in byte5 bits 7-6.
|
|
let ss_sel = b[5] >> 6;
|
|
|
|
let instr = match typ {
|
|
TYPE_LINK_JUMP if direct == 1 => match cmd {
|
|
JP_EXIT => Instr::Exit,
|
|
JP_JUMP_TT => Instr::JumpTt {
|
|
ttn: b[5] & MASK_TTN,
|
|
},
|
|
JP_JUMP_VTS_TT => Instr::JumpVtsTt {
|
|
ttn: b[5] & MASK_TTN,
|
|
},
|
|
JP_JUMP_VTS_PTT => Instr::JumpVtsPtt {
|
|
ttn: b[5] & MASK_TTN,
|
|
pttn: be16(b, 2) & MASK_PTTN,
|
|
},
|
|
JP_JUMP_SS => match ss_sel {
|
|
SS_FP => Instr::JumpSsFp,
|
|
SS_VMGM_MENU => Instr::JumpSsVmgm {
|
|
menu: b[5] & MASK_MENU,
|
|
},
|
|
SS_VTSM => Instr::JumpSsVtsm {
|
|
vts: b[4],
|
|
ttn: b[3],
|
|
menu: b[5] & MASK_MENU,
|
|
},
|
|
_ => Instr::JumpSsVmgmPgc {
|
|
pgcn: be16(b, 2) & MASK_PGCN,
|
|
},
|
|
},
|
|
JP_CALL_SS => Instr::CallSs { sub: ss_sel },
|
|
_ => Instr::Nop,
|
|
},
|
|
TYPE_LINK_JUMP => match cmd {
|
|
// direct == 0 (link). sub-op 0 = NOP/no-link.
|
|
LK_SUB => Instr::LinkSub {
|
|
sub: b[7] & MASK_LINKOP,
|
|
},
|
|
LK_PGCN => Instr::LinkPgcn {
|
|
pgcn: be16(b, 6) & MASK_PGCN,
|
|
},
|
|
LK_PTTN => Instr::LinkPttn {
|
|
pttn: be16(b, 6) & MASK_PTTN,
|
|
},
|
|
LK_PGN => Instr::LinkPgn {
|
|
pgn: b[7] & MASK_PGN,
|
|
},
|
|
LK_CN => Instr::LinkCn { cn: b[7] },
|
|
_ => Instr::Nop,
|
|
},
|
|
TYPE_SPECIAL => match cmd {
|
|
SP_GOTO => Instr::Goto { line: b[7] },
|
|
SP_BREAK => Instr::Break,
|
|
_ => Instr::Nop,
|
|
},
|
|
TYPE_SET_GPRM => Instr::SetGprm {
|
|
reg: b[3] & MASK_REG,
|
|
op: setop,
|
|
immediate: direct != 0,
|
|
imm: be16(b, 4),
|
|
src: b[5],
|
|
},
|
|
TYPE_SET_SYSTEM => Instr::SetSystem,
|
|
_ => Instr::Other(*b),
|
|
};
|
|
|
|
Command { compare, instr }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn h(s: &str) -> [u8; 8] {
|
|
let v: Vec<u8> = (0..8)
|
|
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
|
|
.collect();
|
|
v.try_into().unwrap()
|
|
}
|
|
|
|
// KATs taken from the real SOTL / Greenland discs (decoded in the PoC).
|
|
#[test]
|
|
fn greenland_first_play_is_jumptt_1() {
|
|
let c = decode(&h("3002000000010000"));
|
|
assert_eq!(c.instr, Instr::JumpTt { ttn: 1 });
|
|
assert!(c.compare.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn sotl_first_play_is_jumpss_vtsm_root() {
|
|
// 30 06 ... byte5=0x83 -> sub 2 (VTSM), vts=byte4=1, menu=byte5&0xF=3 (root)
|
|
let c = decode(&h("3006000101830000"));
|
|
assert_eq!(
|
|
c.instr,
|
|
Instr::JumpSsVtsm {
|
|
vts: 1,
|
|
ttn: 1,
|
|
menu: 3
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sotl_title_dispatch_is_conditional_linkpgn_2() {
|
|
// 20 a6 ... CmpLink: if GPRM0 == 2 -> LinkPGN 2 (cell 2 = the 5:02 start)
|
|
let c = decode(&h("20a6000000020002"));
|
|
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
|
let cmp = c.compare.expect("conditional");
|
|
assert_eq!(cmp.op, 2); // ==
|
|
assert_eq!(cmp.lhs_reg, 0); // GPRM0
|
|
assert!(cmp.immediate);
|
|
assert_eq!(cmp.imm, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn sotl_root_button_is_linkpgcn_37() {
|
|
assert_eq!(
|
|
decode(&h("2004000000000025")).instr,
|
|
Instr::LinkPgcn { pgcn: 37 }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn greenland_scene_button_is_linkpgn() {
|
|
assert_eq!(
|
|
decode(&h("2006000000001401")).instr,
|
|
Instr::LinkPgn { pgn: 1 }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn jumpvts_ptt_decodes_ttn_and_pttn() {
|
|
// synthetic: 30 05 | ptt(bytes2-3)=0x0002 | ttn(byte5)=1
|
|
let c = decode(&h("3005000200010000"));
|
|
assert_eq!(c.instr, Instr::JumpVtsPtt { ttn: 1, pttn: 2 });
|
|
}
|
|
|
|
#[test]
|
|
fn setgprm_immediate_mov() {
|
|
// SOTL First-Play pre[0]: 71 00 | reg=byte3=6 | imm(bytes4-5)=0x03e8 -> g6 = 1000
|
|
match decode(&h("7100000603e80000")).instr {
|
|
Instr::SetGprm {
|
|
reg,
|
|
op,
|
|
immediate,
|
|
imm,
|
|
..
|
|
} => {
|
|
assert_eq!(reg, 6);
|
|
assert_eq!(op, 1); // mov
|
|
assert!(immediate);
|
|
assert_eq!(imm, 1000);
|
|
}
|
|
other => panic!("expected SetGprm, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// Regression for the link sub-op decode: 0 = NOP, 1 = LinkSub.
|
|
#[test]
|
|
fn link_subop_zero_is_nop_one_is_linksub() {
|
|
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
|
assert_eq!(
|
|
decode(&h("2001000000000010")).instr,
|
|
Instr::LinkSub { sub: 0x10 }
|
|
);
|
|
}
|
|
|
|
// if_version_1 register compare: rhs register is byte4 (not byte5).
|
|
#[test]
|
|
fn link_register_compare_rhs_is_byte4() {
|
|
// 20 26: link, cmp=EQ(2), dircmp=0(register) ; cmd=6 LinkPGN
|
|
let c = decode(&h("2026000304000002"));
|
|
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
|
let cmp = c.compare.expect("conditional");
|
|
assert!(!cmp.immediate);
|
|
assert_eq!(cmp.lhs_reg, 3);
|
|
assert_eq!(cmp.rhs_reg, 4);
|
|
}
|
|
|
|
// if_version_2 jump compare: both operands are registers in byte6 / byte7.
|
|
#[test]
|
|
fn jump_compare_uses_bytes6_and_7() {
|
|
// 30 22: jump, cmp=EQ(2) ; cmd=2 JumpTT ttn=byte5=5
|
|
let c = decode(&h("3022000000050607"));
|
|
assert_eq!(c.instr, Instr::JumpTt { ttn: 5 });
|
|
let cmp = c.compare.expect("conditional");
|
|
assert!(!cmp.immediate);
|
|
assert_eq!(cmp.lhs_reg, 6);
|
|
assert_eq!(cmp.rhs_reg, 7);
|
|
}
|
|
}
|