use raptor_replica::{ assets::{Catalog, Sprite, TRANSPARENT, contained, i16_at, i32_at}, audio::{DigitalSample, Mixer, MusScore}, content::{ContentPack, Registry}, persistence::{Config, Pilot}, render::upscale, }; fn pilot_registry() -> Registry { let missions: Vec<_> = (1..=3).flat_map(|s| (1..=9).map(move |w| serde_json::json!({ "id":format!("base:sector_{s}_wave_{w}"),"name":"Synthetic wave","duration":100,"legacy_map":100+s*9+w }))).collect(); let pack: ContentPack = serde_json::from_value(serde_json::json!({ "items":[ {"id":"base:forward_guns","name":"Forward Guns","price":12000,"legacy":0}, {"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"legacy":16} ],"missions":missions })).unwrap(); let mut registry = Registry::default(); registry.apply(pack, "base").unwrap(); registry.validate().unwrap(); registry } #[test] fn shop_pack_sales_return_half_the_unit_price_without_creating_credits() { let mut registry = pilot_registry(); let pack = serde_json::from_value(serde_json::json!({ "items":[{"id":"fixture:repair_pack", "name":"Three repairs", "price":900, "initial_count":3, "max_count":8, "consumable":true}] })) .unwrap(); registry.apply(pack, "fixture").unwrap(); registry.validate().unwrap(); let id = "fixture:repair_pack".into(); let item = ®istry.items[&id]; assert_eq!(item.resale_price(), 150); let mut pilot = Pilot { credits: 10_000, ..Pilot::default() }; pilot.buy(&id, ®istry).unwrap(); pilot.buy(&id, ®istry).unwrap(); assert_eq!(pilot.inventory[&id], 6); for remaining in (0..6).rev() { pilot.sell(&id, ®istry).unwrap(); assert_eq!(pilot.item_count(&id), remaining); } assert_eq!(pilot.credits, 9_100); assert!(!pilot.inventory.contains_key(&id)); let credits = pilot.credits; assert!(pilot.sell(&id, ®istry).is_err()); assert_eq!(pilot.credits, credits); assert_eq!(registry.items[&"base:energy".into()].resale_price(), 5_000); } #[test] fn training_graduation_and_sector_cycles_preserve_original_progression() { let registry = pilot_registry(); let mut pilot = Pilot { difficulty: 0, credits: 900000, mission: "base:sector_2_wave_4".into(), ..Pilot::default() }; pilot.complete_mission(®istry).unwrap(); assert_eq!(pilot.mission.0, "base:sector_1_wave_1"); assert_eq!(pilot.difficulty, 1); assert_eq!(pilot.credits, 10000); assert_eq!(pilot.inventory[&"base:energy".into()], 75); assert!(pilot.legacy_progress.as_ref().unwrap().training_completed); pilot.mission = "base:sector_3_wave_9".into(); pilot.difficulty = 2; pilot.complete_mission(®istry).unwrap(); assert_eq!(pilot.mission.0, "base:sector_1_wave_1"); assert_eq!(pilot.legacy_progress.as_ref().unwrap().difficulties[2], 3); assert_eq!(pilot.difficulty, 1); pilot.mission = "base:sector_1_wave_2".into(); pilot.complete_mission(®istry).unwrap(); assert_eq!(pilot.mission.0, "base:sector_1_wave_3"); } #[test] fn legacy_save_import_retains_metadata_without_modifying_source() { fn encrypt(bytes: &[u8]) -> Vec { let key = b"CASTLE"; let mut index = 25 % key.len(); let mut previous = key[index]; bytes .iter() .map(|clear| { let byte = clear.wrapping_add(key[index]).wrapping_add(previous); previous = byte; index = (index + 1) % key.len(); byte }) .collect() } let mut header = vec![0u8; 88]; header[..9].copy_from_slice(b"Synthetic"); header[20..24].copy_from_slice(b"TEST"); for (offset, value) in [ (32, 65814i32), (36, 98765), (40, -1), (44, 1), (48, 3), (52, 8), (56, 1), (60, 2), (64, 0), (68, 2), (72, 1), (76, 3), (80, 0), (84, 1), ] { header[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } let mut body = vec![0u8; 48]; for (offset, value) in [(8, 1i32), (12, 0), (32, 71), (36, 16)] { body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } let bytes = [encrypt(&header), encrypt(&body)].concat(); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("CHAR0000.FIL"); std::fs::write(&path, &bytes).unwrap(); let registry = pilot_registry(); let pilot = Pilot::import_legacy(&path, ®istry).unwrap(); assert_eq!(pilot.callsign, "TEST"); assert_eq!(pilot.credits, 98765); assert_eq!(pilot.mission.0, "base:sector_2_wave_9"); assert_eq!(pilot.inventory[&"base:energy".into()], 71); assert_eq!(pilot.completed.len(), 12); let progress = pilot.legacy_progress.as_ref().unwrap(); assert_eq!(progress.portrait, 65814); assert_eq!(progress.difficulties, [0, 2, 1, 3]); assert!(progress.training_completed); let output = dir.path().join("pilot.json"); pilot.save(&output).unwrap(); assert_eq!(Pilot::load(&output, ®istry, &[]).unwrap().credits, 98765); assert_eq!(std::fs::read(path).unwrap(), bytes); } fn header(kind: i32, width: i32, height: i32) -> Vec { [kind, 0, 0, width, height] .into_iter() .flat_map(i32::to_le_bytes) .collect() } #[test] fn raw_pictures_keep_zero_opaque_and_spans_keep_empty_pixels_transparent() { let mut raw = header(1, 2, 1); raw.extend([0, 255]); assert_eq!(Sprite::decode(&raw).unwrap().pixels, [0, 255]); let mut span = header(0, 3, 2); span.extend([1i32, 1, 4, 2].into_iter().flat_map(i32::to_le_bytes)); span.extend([0, 17]); span.extend([0i32, 0, -1, 0].into_iter().flat_map(i32::to_le_bytes)); assert_eq!( Sprite::decode(&span).unwrap().pixels, [TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, 0, 17] ); for n in 0..span.len() { assert!(Sprite::decode(&span[..n]).is_err()); } } #[test] fn malformed_fields_and_spans_fail_without_panicking() { assert!(i16_at(&[], usize::MAX).is_err()); assert!(i32_at(&[], usize::MAX).is_err()); let mut span = header(0, 2, 2); span.extend([i32::MAX, 0, 0, 2].into_iter().flat_map(i32::to_le_bytes)); span.extend([1, 2]); assert!(Sprite::decode(&span).is_err()); assert!(Sprite::decode(&header(1, 4096, 4096)).is_err()); } #[test] fn scale_kernels_have_published_corner_results() { let input = Sprite { width: 3, height: 3, pixels: vec![9, 1, 8, 1, 2, 3, 7, 4, 6], }; let two = upscale(&input, 2).unwrap(); let four = upscale(&input, 4).unwrap(); assert_eq!( [ two.pixels[2 * 6 + 2], two.pixels[2 * 6 + 3], two.pixels[3 * 6 + 2], two.pixels[3 * 6 + 3] ], [1, 2, 2, 2] ); let three = upscale(&input, 3).unwrap(); let center: Vec<_> = (3..6) .flat_map(|y| (3..6).map(move |x| y * 9 + x)) .map(|n| three.pixels[n]) .collect(); assert_eq!(center, [1, 1, 2, 1, 2, 2, 2, 2, 2]); assert_eq!(four, upscale(&two, 2).unwrap()); assert!(upscale(&input, 5).is_err()); } #[test] fn catalog_paths_cannot_escape_or_follow_an_external_symlink() { let root = tempfile::tempdir().unwrap(); let other = tempfile::tempdir().unwrap(); std::fs::write(other.path().join("outside"), b"outside").unwrap(); assert!(contained(root.path(), std::path::Path::new("../outside")).is_err()); #[cfg(unix)] { std::os::unix::fs::symlink(other.path().join("outside"), root.path().join("link")).unwrap(); assert!(contained(root.path(), std::path::Path::new("link")).is_err()); } } #[test] fn importing_settings_preserves_source_and_uses_sdl_key_names() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("SETUP.INI"); let original = b"[Keyboard]\nFire=29\nFireSp=56\nMegaFire=54\n[Video]\nupscaler=3\n"; std::fs::write(&path, original).unwrap(); let config = Config::import_ini(&path).unwrap(); assert_eq!(config.scale, 3); assert_eq!(config.bindings["fire"], "Left Ctrl"); assert_eq!(config.bindings["special"], "Z"); assert_eq!(config.bindings["cycle"], "Left Alt"); assert_eq!(config.bindings["bomb"], "Space"); assert_eq!(std::fs::read(path).unwrap(), original); #[cfg(feature = "desktop")] for key in config.bindings.values() { assert!( sdl2::keyboard::Scancode::from_name(key).is_some(), "unknown SDL key {key}" ); } } // Original pixels never enter the test fixtures or version control. Set this // variable only on a machine with the licensed extracted asset catalog. #[test] fn original_data_decoders_and_all_campaigns_when_available() { let Some(data) = std::env::var_os("RAPTOR_TEST_DATA") else { return; }; let catalog = Catalog::open(data).unwrap(); let registry = Registry::base(&catalog).unwrap(); registry.validate().unwrap(); assert_eq!(registry.missions.len(), 27); assert_eq!(registry.campaigns.len(), 3); let mut sprites = 0; for entry in catalog.entries.values() { let bytes = catalog.read(entry.id).unwrap(); if bytes.len() >= 20 && (i32_at(&bytes, 0).ok() == Some(0) || (i32_at(&bytes, 0).ok() == Some(1) && i32_at(&bytes, 4).ok() == Some(1) && i32_at(&bytes, 8).ok() == Some(0))) && matches!(i32_at(&bytes, 12).ok(), Some(1..=4096)) && matches!(i32_at(&bytes, 16).ok(), Some(1..=4096)) { Sprite::decode(&bytes).unwrap_or_else(|e| panic!("asset {:x}: {e}", entry.id)); sprites += 1; } if bytes.starts_with(b"MUS\x1a") { MusScore::decode(&bytes).unwrap(); } } assert!( sprites > 1000, "format discovery must include unnamed graphics" ); for sector in 0..3 { let pilot = Pilot::demo(sector, ®istry).unwrap(); pilot.validate(®istry, &[]).unwrap(); let selected = ®istry.items[pilot.selected.as_ref().unwrap()]; assert_eq!(selected.legacy, Some([5, 8, 8][sector])); } let mut mixer = Mixer::new(44100, &catalog.named("GENMIDI_OP2").unwrap()).unwrap(); mixer.play( MusScore::decode(&catalog.named("RAP1_MUS").unwrap()).unwrap(), false, ); let mut output = vec![0i16; 44100 * 2]; mixer.mix(&mut output); assert!( output.iter().any(|v| *v != 0), "FM playback must produce sound" ); let sample = catalog .entries .values() .find_map(|e| DigitalSample::decode(&catalog.read(e.id).ok()?).ok()) .unwrap(); mixer.sample(sample, 128, 128, 127, 1); mixer.mix(&mut output); assert!(output.iter().any(|v| *v != 0)); }