use raptor_replica::{ content::{ContentPack, Registry}, modding::{self, ActiveMods, Command, ScriptRuntime}, persistence::Pilot, simulation::{Input, Outcome, World}, sound, }; use serde_json::json; fn digital_patch() -> Vec { let data: Vec<_> = (0..4000) .map(|i| if i % 20 < 10 { 240 } else { 16 }) .collect(); let mut bytes = vec![3, 0]; bytes.extend(11025_u16.to_le_bytes()); bytes.extend(((data.len() + 32) as i32).to_le_bytes()); bytes.extend([0; 16]); bytes.extend(data); bytes.extend([0; 16]); bytes } fn original_catalog() -> (tempfile::TempDir, raptor_replica::assets::Catalog) { let root = tempfile::tempdir().unwrap(); std::fs::write(root.path().join("marker"), []).unwrap(); let bytes = digital_patch(); std::fs::write(root.path().join("digital"), &bytes).unwrap(); std::fs::write( root.path().join("INDEX"), format!("00090 0 marker\n00094 {} digital\n", bytes.len()), ) .unwrap(); let catalog = raptor_replica::assets::Catalog::open(root.path()).unwrap(); (root, catalog) } fn mixer() -> raptor_replica::audio::Mixer { let mut genmidi = b"#OPL_II#".to_vec(); genmidi.resize(8 + 175 * 36, 0); let mut mixer = raptor_replica::audio::Mixer::new(44100, &genmidi).unwrap(); mixer.fx_volume = 127; mixer } #[test] fn original_marker_resolves_digital_variant_and_keeps_decoded_cache() { let (root, catalog) = original_catalog(); assert!(raptor_replica::audio::DigitalSample::decode(&catalog.read(144).unwrap()).is_err()); let mut bank = sound::SoundBank::default(); let resolved = bank.original(&catalog, 144).unwrap(); assert_eq!(resolved.data.len(), 4000); assert_eq!(bank.original_count(), 1); std::fs::remove_file(root.path().join("digital")).unwrap(); assert_eq!(bank.original(&catalog, 144).unwrap().data, resolved.data); assert!(bank.original(&catalog, 999).is_err()); } #[test] fn live_world_firing_reaches_the_native_digital_mixer() { let (_root, catalog) = original_catalog(); let mut registry = registry(); registry .missions .get_mut(&Pilot::default().mission) .unwrap() .script = None; let content: ContentPack = serde_json::from_value(json!({ "weapons":[{"id":"base:test_guns","name":"Test guns","damage":2,"speed":8,"cooldown":2,"target":"all","behavior":"straight","legacy":0}] })).unwrap(); registry.apply(content, "base").unwrap(); registry .items .get_mut(&"base:forward_guns".into()) .unwrap() .weapon = Some("base:test_guns".into()); let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap(); world .step( Input { fire: true, ..Input::default() }, ®istry, &mut ScriptRuntime::new(), ) .unwrap(); let event = world .sound_events .iter() .find(|s| s.asset == 144) .expect("firing did not emit native gun sound"); let mut mixer = mixer(); let mut bank = sound::SoundBank::default(); bank.queue_original(&catalog, &mut mixer, event).unwrap(); let mut pcm = vec![0; 4096]; mixer.mix(&mut pcm); assert!(pcm.iter().any(|s| *s != 0)); } #[cfg(feature = "desktop")] #[test] fn production_sdl_callback_outputs_and_captures_resolved_original_effect() { use sdl2::audio::AudioCallback; let (_root, catalog) = original_catalog(); let mut mixer = mixer(); mixer.start_capture(1).unwrap(); let mut bank = sound::SoundBank::default(); bank.queue_original(&catalog, &mut mixer, &sound::ui_event("FX_GUN").unwrap()) .unwrap(); let mut output = [99; 4096]; AudioCallback::callback(&mut mixer, &mut output); assert!(output.iter().any(|s| *s != 0)); assert!(output.chunks_exact(2).any(|s| s[0] != 0 && s[1] != 0)); assert_eq!(mixer.take_capture().unwrap().samples, output); } #[cfg(feature = "desktop")] #[test] #[ignore = "requires the original untracked catalog; uses only the Rust audio callback"] fn all_original_effect_cues_reach_the_production_callback_locally() { use raptor_replica::{assets::Catalog, audio::Mixer}; use sdl2::audio::AudioCallback; let root = std::env::var_os("RAPTOR_TEST_DATA") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../data")); let catalog = Catalog::open(root).unwrap(); let instruments = catalog.named("GENMIDI_OP2").unwrap(); let mut bank = sound::SoundBank::default(); bank.preload_originals(&catalog).unwrap(); assert_eq!(bank.original_count(), 26); let names = [ "FX_MON1", "FX_MON2", "FX_MON3", "FX_MON4", "FX_MON5", "FX_MON6", "FX_DAVE", "FX_THEME", "FX_AIREXPLO", "FX_AIREXPLO2", "FX_BONUS", "FX_CRASH", "FX_DOOR", "FX_FLYBY", "FX_EGRAB", "FX_GEXPLO", "FX_GUN", "FX_JETSND", "FX_LASER", "FX_MISSLE", "FX_SWEP", "FX_TURRET", "FX_WARNING", "FX_BOSS1", "FX_IJETSND", "FX_EJETSND", "FX_INTROHIT", "FX_INTROGUN", "FX_ENEMYSHOT", "FX_ENEMYLASER", "FX_ENEMYMISSLE", "FX_ENEMYPLASMA", "FX_SHIT", "FX_HIT", "FX_NOSHOOT", "FX_PULSE", ]; for name in names { let mut mixer = Mixer::new(44100, &instruments).unwrap(); mixer.fx_volume = 127; bank.queue_original(&catalog, &mut mixer, &sound::ui_event(name).unwrap()) .unwrap(); let mut output = vec![0; 44100 * 2]; AudioCallback::callback(&mut mixer, &mut output); assert!(output.iter().any(|s| *s != 0), "silent original cue {name}"); } println!( "36 original cue variants produced PCM through the Rust SDL callback; 26 cached digital assets" ); } fn wav(channels: u16, bits: u16, data: &[u8]) -> Vec { let align = channels * bits / 8; let mut bytes = b"RIFF".to_vec(); bytes.extend((36u32 + data.len() as u32 + (data.len() as u32 % 2)).to_le_bytes()); bytes.extend(b"WAVEfmt "); bytes.extend(16u32.to_le_bytes()); bytes.extend(1u16.to_le_bytes()); bytes.extend(channels.to_le_bytes()); bytes.extend(11025u32.to_le_bytes()); bytes.extend((11025u32 * align as u32).to_le_bytes()); bytes.extend(align.to_le_bytes()); bytes.extend(bits.to_le_bytes()); bytes.extend(b"data"); bytes.extend((data.len() as u32).to_le_bytes()); bytes.extend(data); if data.len() % 2 == 1 { bytes.push(0); } bytes } fn registry() -> Registry { let mut registry = Registry::default(); let pack: ContentPack=serde_json::from_value(json!({ "items":[{"id":"base:energy","name":"Shield","price":0,"max_count":100}, {"id":"base:forward_guns","name":"Guns","price":0}], "missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1,"auto_complete":false,"script":"mission.rhai"}], "sounds":[{"id":"base:test","name":"Test","file":"test.wav"}] })).unwrap(); registry.apply(pack, "base").unwrap(); registry.validate().unwrap(); registry } #[test] fn wav_handles_padding_unsigned_eight_bit_and_stereo_sixteen_bit() { let decoded = sound::decode(&wav(1, 8, &[0, 128, 255])).unwrap(); assert_eq!(decoded.data, [0, 128, 255]); assert_eq!(decoded.rate, 11025); let samples: Vec<_> = [-32768i16, 32767, -32768, -32768, 32767, 32767] .into_iter() .flat_map(i16::to_le_bytes) .collect(); assert_eq!( sound::decode(&wav(2, 16, &samples)).unwrap().data, [128, 0, 255] ); } #[test] fn malformed_wave_ranges_formats_and_padding_fail() { let bytes = wav(1, 8, &[128, 200, 0]); for end in 0..bytes.len() { assert!(sound::decode(&bytes[..end]).is_err()); } assert!(sound::decode(&wav(3, 8, &[0, 0, 0])).is_err()); assert!(sound::decode(&wav(1, 32, &[0, 0, 0, 0])).is_err()); let mut huge = bytes.clone(); huge[40..44].copy_from_slice(&u32::MAX.to_le_bytes()); assert!(sound::decode(&huge).is_err()); } #[test] fn scripted_completion_emits_end_hook_once_and_sound_ids_are_checked() { let registry = registry(); let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap(); let mut scripts = ScriptRuntime::new(); scripts.add("base","mission.rhai",r#" fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} } fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:#{ended:1}} } "#).unwrap(); world .step(Input::default(), ®istry, &mut scripts) .unwrap(); assert_eq!(world.outcome, Outcome::Complete); assert_eq!(world.mod_sounds.len(), 1); world .step(Input::default(), ®istry, &mut scripts) .unwrap(); assert_eq!(world.mod_sounds.len(), 1); assert!( world .apply_commands( &[Command::PlaySound { sound: "base:missing".into() }], ®istry ) .is_err() ); } #[test] fn scripted_mod_cue_outputs_pcm_and_cache_is_cleared_when_mods_change() { let root = tempfile::tempdir().unwrap(); let mut registry = registry(); registry .asset_roots .insert("base".into(), root.path().to_path_buf()); let path = root.path().join("test.wav"); std::fs::write(&path, wav(1, 8, &vec![240; 2000])).unwrap(); let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap(); let mut scripts = ScriptRuntime::new(); scripts.add("base", "mission.rhai", r#" fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} } fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:state} } "#).unwrap(); world .step(Input::default(), ®istry, &mut scripts) .unwrap(); assert_eq!(world.mod_sounds.len(), 1); let id = &world.mod_sounds[0]; let mut bank = sound::SoundBank::default(); let mut audible = mixer(); bank.queue_mod(®istry, &mut audible, id).unwrap(); let mut pcm = [0; 1024]; audible.mix(&mut pcm); assert!(pcm.iter().any(|s| *s != 0)); std::fs::write(&path, wav(1, 8, &vec![128; 2000])).unwrap(); assert!( bank.mod_sample(®istry, id) .unwrap() .data .iter() .any(|v| *v != 128) ); bank.clear_mods(); let mut replaced = mixer(); bank.queue_mod(®istry, &mut replaced, id).unwrap(); replaced.mix(&mut pcm); assert!(pcm.iter().all(|s| *s == 0)); } #[test] fn package_local_sounds_validate_and_missing_files_reject_activation() { let dir = tempfile::tempdir().unwrap(); let package = dir.path().join("tone"); std::fs::create_dir_all(&package).unwrap(); std::fs::write( package.join("mod.toml"), "id='tone'\nname='Tone'\nversion='1.0.0'\napi_version=1\ncontent='content.json'\n", ) .unwrap(); std::fs::write( package.join("content.json"), serde_json::to_vec(&json!({"sounds":[{"id":"tone:shot","name":"Shot","file":"shot.wav"}]})) .unwrap(), ) .unwrap(); std::fs::write(package.join("shot.wav"), wav(1, 8, &[128, 200, 50])).unwrap(); let found = modding::discover(dir.path()); assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_ok()); std::fs::remove_file(package.join("shot.wav")).unwrap(); let found = modding::discover(dir.path()); assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_err()); }