//! Integration tests against a real disc image. //! //! These are skipped unless `GC_DISC` points at a legally obtained disc, since //! the repository must not contain game data. When it is set they check the //! identification and unpack results against the committed hash table. //! //! ```sh //! GC_DISC="path/to/disc.iso" cargo test -p gc-extract --test disc //! ``` use std::path::{Path, PathBuf}; /// The disc under test, if the environment names one. fn disc() -> Option { let p = std::env::var_os("GC_DISC")?; let p = PathBuf::from(p); p.is_file().then_some(p) } /// Skip a test with a clear reason rather than failing. macro_rules! require_disc { () => { match disc() { Some(p) => p, None => { eprintln!("skipped: set GC_DISC to a Going Commando disc image to run this"); return; } } }; } #[test] fn identifies_the_supported_build() { let path = require_disc!(); let info = gc_extract::identify::identify(&path).expect("disc should identify"); assert_eq!(info.serial, "SCUS_972.68"); assert_eq!(info.volume_id, "RATCHETANDCLANK2"); assert!( info.is_target(), "expected the supported build, got: {}", info.verdict() ); assert_eq!( info.boot_elf_sha1, gc_extract::identify::TARGET_BOOT_ELF_SHA1, "boot ELF SHA-1 differs from the recorded retail value" ); assert_eq!(info.boot_elf_size, 2_618_684); // This disc is NTSC; the movement constants assume 60 Hz. assert!(!info.is_pal(), "test disc should be NTSC"); } #[test] fn agrees_with_the_python_reference_identifier() { let path = require_disc!(); let info = gc_extract::identify::identify(&path).expect("disc should identify"); // tools/identify-disc.py is the reference implementation and prints these // two values. If they ever disagree, one of them has a bug. // Run from the workspace root so the relative path to the tool resolves. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(|p| p.parent()) .expect("the crate should live two levels below the workspace root") .to_path_buf(); let py = std::process::Command::new("python3") .arg(root.join("tools/identify-disc.py")) .arg(&path) .current_dir(&root) .output() .expect("python3 should be available to run the reference identifier"); assert!( py.status.success(), "the reference identifier failed:\n{}", String::from_utf8_lossy(&py.stderr) ); let text = String::from_utf8_lossy(&py.stdout); assert!( text.contains(&format!("serial {}", info.serial)), "serial disagrees with the reference identifier:\n{text}" ); assert!( text.contains(&format!("SHA-1 {}", info.boot_elf_sha1)), "boot ELF SHA-1 disagrees with the reference identifier:\n{text}" ); } #[test] fn unpacks_and_matches_the_committed_table() { let path = require_disc!(); let table_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("data/gc-files.tsv"); let table_text = std::fs::read_to_string(&table_path).expect("the hash table should be committed"); let expected = gc_extract::verify::parse_table(&table_text); assert!( expected.len() > 5000, "expected a full table, got {} rows", expected.len() ); // Unpack into a temporary directory so the test leaves nothing behind. let tmp = std::env::temp_dir().join(format!("gc-unpack-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).expect("should create a temp directory"); let files = gc_extract::unpack::unpack(&path, &tmp).expect("unpack should succeed"); // Every unpacked file must match the recorded size and hash. let report = gc_extract::verify::verify(&tmp, &expected); let _ = std::fs::remove_dir_all(&tmp); assert!( report.ok(), "unpacked data does not match the committed table:\n{}", report.summary() ); assert_eq!( files.len(), expected.len(), "unpack produced a different file count" ); } #[test] fn every_container_parses() { let path = require_disc!(); let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open"); let containers: Vec<_> = iso .files .iter() .filter(|e| e.name.starts_with("G/") && e.name.ends_with(".WAD")) .collect(); // 92 `G/*.WAD` containers: 27 LEVEL, 27 AUDIO, 27 SCENE, 11 globals. assert_eq!(containers.len(), 92, "expected 92 asset containers"); // MPEG1 and MPEG2 are raw video streams with no table; the other 90 must // all parse as indexed containers. let raw_video: Vec<&str> = containers .iter() .map(|c| c.name.as_str()) .filter(|n| *n == "G/MPEG1.WAD" || *n == "G/MPEG2.WAD") .collect(); assert_eq!( raw_video.len(), 2, "expected MPEG1 and MPEG2 to be the raw streams" ); // The per-level families must all be present, at 27 each. for family in ["LEVEL", "AUDIO", "SCENE"] { let n = containers .iter() .filter(|c| { let stem = c.name.trim_start_matches("G/").trim_end_matches(".WAD"); stem.strip_prefix(family) .map(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit())) .unwrap_or(false) }) .count(); assert_eq!(n, 27, "expected 27 {family}.WAD containers, found {n}"); } for entry in &containers { if raw_video.contains(&entry.name.as_str()) { continue; } let data = gc_formats::iso9660::read_at(&path, entry.lba, entry.size) .expect("read should succeed"); let kind = gc_formats::global_wad::RangeKind::for_container(&entry.name); let container = gc_formats::global_wad::GlobalWad::parse_with(&data, kind) .unwrap_or_else(|e| panic!("{} failed to parse: {e}", entry.name)); assert!( !container.entries.is_empty(), "{} parsed but had no entries", entry.name ); } } /// The level container header is the first typed structure we parse from real /// data, and the tiling invariant is the check that holds on every level rather /// than one hand-picked sample. #[test] fn level_headers_tile_their_containers() { let path = require_disc!(); let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open"); let mut checked = 0; for entry in iso.files.iter().filter(|e| e.name.starts_with("G/LEVEL")) { let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header"); let h = gc_formats::level_wad::LevelHeader::parse(&data) .unwrap_or_else(|e| panic!("{}: {e}", entry.name)); assert_eq!(h.header_size, 0x60, "{}", entry.name); assert_eq!( h.runtime_sector, 0, "{}: runtime sector field should be 0 on disc", entry.name ); // Every present extent must start where the previous one ended. let extents = h.ordered_extents(); for w in extents.windows(2) { let (pn, p) = w[0]; let (nn, n) = w[1]; assert_eq!( p.offset + p.size, n.offset, "{}: gap or overlap between {} and {}", entry.name, pn.name(), nn.name() ); } // Nothing may fall outside the container. The last extent may end one // sector past the file end through sector rounding, hence `+ 1`. let container_sectors = (entry.size / 2048) as i32; for (name, r) in &extents { assert!( r.offset >= 0 && r.offset + r.size <= container_sectors + 1, "{}: {} extent {:?} is outside the container ({container_sectors} sectors)", entry.name, name.name(), r ); } checked += 1; } assert_eq!(checked, 27, "expected 27 level containers"); } /// Level ids are not file order: `LEVEL21.WAD` reports 30. Anything that keys /// off the container index would silently mislabel that planet. #[test] fn level_ids_are_not_container_indices() { let path = require_disc!(); let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open"); // The ISO filesystem lists names alphabetically, so LEVEL10 sorts before // LEVEL2. Order by the numeric suffix, which is what the container names // mean. let mut levels: Vec<(usize, &gc_formats::iso9660::DirEntry)> = iso .files .iter() .filter(|e| e.name.starts_with("G/LEVEL")) .map(|e| { let n: usize = e .name .trim_start_matches("G/LEVEL") .trim_end_matches(".WAD") .parse() .expect("level container names end in a number"); (n, e) }) .collect(); levels.sort_by_key(|(n, _)| *n); let mut mismatches = Vec::new(); for (index, (_, entry)) in levels.iter().enumerate() { let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header"); let h = gc_formats::level_wad::LevelHeader::parse(&data).expect("parse"); if h.id as usize != index { mismatches.push(format!( "{}: id {} but is container {index}", entry.name, h.id )); } } // Exactly one level is out of step: the gap left by a removed level. assert_eq!( mismatches.len(), 1, "expected exactly one level whose id differs from its container index, got {mismatches:?}" ); assert!( mismatches[0].contains("LEVEL21"), "unexpected mismatch: {mismatches:?}" ); } /// Sizes in the level header are sector counts. If they were byte counts, the /// extents could not tile a container this size, so this also guards against /// "fixing" the parser to read them as bytes. #[test] fn level_extent_sizes_are_sector_counts() { let path = require_disc!(); let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open"); let entry = iso.find("G/LEVEL0.WAD").expect("level 0"); let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header"); let h = gc_formats::level_wad::LevelHeader::parse(&data).expect("parse"); // Sound bank occupies sectors 1..735 and data 735..7219. assert_eq!(h.sound_bank.offset, 1); assert_eq!(h.sound_bank.size, 734); assert_eq!(h.data.offset, 735); // Read as bytes, 734 would be under four sectors and the tiling would break. assert_eq!(h.sound_bank.byte_len(), 734 * 2048); }