use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use dotzuki_renderer::input::{GbButton, InputState};
use dotzuki_runner::{LoadedProject, RunnerAudio, RunnerGame, RunnerOptions};
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new(test: &str) -> Self {
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"dotzuki-runner-audio-{test}-{}-{id}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn copy_dir(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let to = dst.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_dir(&entry.path(), &to);
} else {
fs::copy(entry.path(), &to).unwrap();
}
}
}
const THEME_JSON: &str = r#"{
"id": "theme",
"kind": "music",
"tempo": 256,
"channels": [
{
"hw": "pulse1",
"commands": [
{ "type": "note_type", "speed": 12, "param": 197 },
{ "type": "octave", "value": 5 },
{ "type": "note", "pitch": 0, "length": 4 },
{ "type": "rest", "length": 4 },
{ "type": "sound_ret" }
]
}
]
}"#;
const BEEP_JSON: &str = r#"{
"id": "beep",
"kind": "sfx",
"channels": [
{
"hw": "pulse1",
"commands": [
{ "type": "duty_cycle", "value": 2 },
{ "type": "sfx_square_note", "length": 4, "volume_envelope": 145, "frequency": 1984 },
{ "type": "sound_ret" }
]
}
]
}"#;
fn write_audio_dir(root: &Path) {
let music = root.join("data/audio/music");
let sfx = root.join("data/audio/sfx");
fs::create_dir_all(&music).unwrap();
fs::create_dir_all(&sfx).unwrap();
fs::write(music.join("theme.json"), THEME_JSON).unwrap();
fs::write(sfx.join("beep.json"), BEEP_JSON).unwrap();
}
fn write_tileset(map_dir: &Path) {
let tile = 16u32;
let mut img = image::RgbaImage::new(tile * 4, tile);
for i in 0..4 {
for y in 0..tile {
for x in 0..tile {
img.put_pixel(i * tile + x, y, image::Rgba([0xFF, 0xFF, 0xFF, 0xFF]));
}
}
}
img.save(map_dir.join("tileset.png")).unwrap();
}
fn demo_project(test: &str) -> (TestDir, PathBuf) {
let tmp = TestDir::new(test);
let root = tmp.path().join("demo");
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo");
copy_dir(&fixture, &root);
write_tileset(&root.join("data/maps/Town"));
write_tileset(&root.join("data/maps/Cave"));
(tmp, root)
}
#[test]
fn loads_tracks_recursively_from_data_audio() {
let (_tmp, root) = demo_project("load");
write_audio_dir(&root);
let audio = RunnerAudio::new(&root.join("data"), false);
assert_eq!(audio.track_count(), 2);
assert!(audio.has_track("theme"));
assert!(audio.has_track("beep"));
assert!(!audio.has_track("nope"));
assert!(!audio.has_output(), "no play command ⇒ no device init");
}
#[test]
fn missing_audio_dir_is_empty_and_silent() {
let (_tmp, root) = demo_project("nodir");
let mut audio = RunnerAudio::new(&root.join("data"), true);
assert_eq!(audio.track_count(), 0);
audio.play_music("theme");
audio.play_sound("beep");
audio.stop_music();
audio.fade_out_music();
audio.update_frame();
assert!(!audio.has_output());
}
#[test]
fn unknown_ids_are_noops_without_a_device() {
let (_tmp, root) = demo_project("unknown");
write_audio_dir(&root);
let mut audio = RunnerAudio::new(&root.join("data"), false);
audio.play_music("nope");
audio.play_music("nope");
audio.play_sound("also-nope");
assert!(!audio.has_output(), "unknown ids must not trigger device init");
}
#[test]
fn device_disallowed_stays_silent_with_known_tracks() {
let (_tmp, root) = demo_project("headless");
write_audio_dir(&root);
let mut audio = RunnerAudio::new(&root.join("data"), false);
audio.play_music("theme");
audio.play_sound("beep");
for _ in 0..90 {
audio.update_frame(); }
audio.fade_out_music();
audio.stop_music();
assert!(!audio.has_output(), "headless must never open a device");
}
fn frame(game: &mut RunnerGame, mask: u8) {
let mut input = InputState::new();
input.set_from_bitmask(mask);
game.update(&input);
}
fn idle(game: &mut RunnerGame, n: u32) {
for _ in 0..n {
frame(game, 0);
}
}
fn press_a(game: &mut RunnerGame) {
frame(game, GbButton::A.bit_mask());
idle(game, 1);
}
fn dismiss_dialogue(game: &mut RunnerGame) {
for _ in 0..20 {
if game.dialogue_text().is_none() && game.choice_options().is_none() {
return;
}
press_a(game);
}
panic!("dialogue did not close after 20 A presses");
}
#[test]
fn play_commands_resolve_and_the_scene_continues() {
let (_tmp, root) = demo_project("pump");
write_audio_dir(&root);
fs::write(
root.join("data/maps/Cave/script.scene"),
r#"game_scene Cave {
@storyline("cave_enter") {
@trigger(map = "Cave", on_enter = true)
@command("playMusic", "theme")
@command("playSound", "beep")
@command("playMusic", "no-such-track")
@command("fadeOutMusic")
@command("stopMusic")
@speaker("") {
"A cold wind blows through the cave."
}
}
}
"#,
)
.unwrap();
let project = LoadedProject::load(&root).expect("load demo project");
let mut game = RunnerGame::new(
project,
RunnerOptions {
map: Some("Cave".to_string()),
headless: true, ..RunnerOptions::default()
},
)
.expect("boot game");
assert_eq!(game.audio().track_count(), 2);
let page = game.dialogue_text().expect("scene continues past audio commands");
assert!(page.contains("cold wind"), "page: {page:?}");
dismiss_dialogue(&mut game);
idle(&mut game, 30);
assert!(!game.audio().has_output());
}
#[test]
fn pcm_mode_renders_audio_headless() {
let (_tmp, root) = demo_project("pcm");
write_audio_dir(&root);
fs::write(
root.join("data/maps/Cave/script.scene"),
r#"game_scene Cave {
@storyline("cave_enter") {
@trigger(map = "Cave", on_enter = true)
@command("playMusic", "theme")
}
}
"#,
)
.unwrap();
let project = LoadedProject::load(&root).expect("load demo project");
let mut game = RunnerGame::new(
project,
RunnerOptions {
map: Some("Cave".to_string()),
headless: true, pcm_audio: true,
..RunnerOptions::default()
},
)
.expect("boot game");
assert_eq!(game.audio().track_count(), 2);
idle(&mut game, 10);
assert!(!game.audio().has_output(), "pcm mode must never open a device");
let pcm = game.render_audio(4410);
assert_eq!(pcm.len(), 8820, "stereo frames: 2 * frames");
assert!(
pcm.iter().any(|s| *s != 0.0),
"playing music must render non-silent PCM"
);
}