diff --git a/src/apu.rs b/src/apu.rs new file mode 100644 index 0000000..583baea --- /dev/null +++ b/src/apu.rs @@ -0,0 +1,316 @@ +//! Emulates the APU (audio processing unit) +use bitflags::bitflags; + +#[derive(Default)] +pub struct APU { + pulse_1: PulseGenerator, + pulse_2: PulseGenerator, + // APU can run in two "modes", which affect timing and interrupts + mode_toggle: bool, + cycles: u16, +} + +impl APU { + pub fn tick(&mut self) -> u8 { + let pulse_1 = self.pulse_1.tick(); + let pulse_2 = self.pulse_2.tick(); + + let cycles = self.cycles; + self.cycles += 1; + + match (self.mode_toggle, cycles) { + (_, 3728) | (_, 11185) => { + self.pulse_1.envelope.clock(); + self.pulse_2.envelope.clock(); + } + (_, 7456) | (false, 14914) | (true, 18640) => { + self.pulse_1.envelope.clock(); + self.pulse_2.envelope.clock(); + self.pulse_1.clock_length_counter(); + self.pulse_2.clock_length_counter(); + } + (false, 14915) | (true, 18641) => { + self.cycles = 0; + } + _ => {} + } + + pulse_1 + pulse_2 + } + + pub fn write_pulse_1_flags(&mut self, value: u8) { + self.pulse_1.write_flags(value); + } + + pub fn write_pulse_1_timer(&mut self, value: u8) { + self.pulse_1.write_timer(value); + } + + pub fn write_pulse_1_length(&mut self, value: u8) { + self.pulse_1.write_length(value); + } + + pub fn write_pulse_2_flags(&mut self, value: u8) { + self.pulse_2.write_flags(value); + } + + pub fn write_pulse_2_timer(&mut self, value: u8) { + self.pulse_2.write_timer(value); + } + + pub fn write_pulse_2_length(&mut self, value: u8) { + self.pulse_2.write_length(value); + } + + pub fn write_frame_counter(&mut self, value: u8) { + let value = FrameCounter::from_bits_truncate(value); + self.mode_toggle = value.contains(FrameCounter::MODE); + } + + pub fn read_status(&mut self) -> u8 { + let mut status = Status::empty(); + status.set(Status::PULSE_1, !self.pulse_1.halted()); + status.set(Status::PULSE_2, !self.pulse_2.halted()); + status.bits() + } + + pub fn write_status(&mut self, value: u8) { + let status = Status::from_bits_truncate(value); + self.pulse_1.set_enabled(status.contains(Status::PULSE_1)); + self.pulse_2.set_enabled(status.contains(Status::PULSE_2)); + } +} + +#[derive(Default)] +// A 'pulse wave' is a rectangular wave (alternating from high to low). +struct PulseGenerator { + enabled: bool, + // `timer` starts at `timer_initial` and counts down to 0. + // When it reaches 0, it is reloaded with `timer_initial` and `sequencer` is incremented. + // A lower `timer_initial` value results in a higher frequency. + timer_initial: u16, + timer: u16, + // The index into the waveform. + sequencer: u8, + duty_cycle: u8, + length_counter: u8, + length_counter_halt: bool, + envelope: Envelope, +} + +impl PulseGenerator { + fn set_enabled(&mut self, enabled: bool) { + if !enabled { + self.length_counter = 0; + } + self.enabled = enabled; + } + + fn write_flags(&mut self, value: u8) { + let flags = PulseFlags::from_bits_truncate(value); + self.duty_cycle = flags.bits() >> 6; + self.length_counter_halt = flags.contains(PulseFlags::LENGTH_COUNTER_HALT); + self.envelope.constant_volume = flags.contains(PulseFlags::CONSTANT_VOLUME); + self.envelope.volume = (flags & PulseFlags::VOLUME).bits(); + } + + fn write_timer(&mut self, value: u8) { + // Set the low bits of the timer + self.timer_initial = (self.timer_initial & 0xFF00) | value as u16; + } + + fn write_length(&mut self, value: u8) { + // Set the high bits of the timer + let value = Length::from_bits_truncate(value); + let timer_high = (value & Length::TIMER_HIGH).bits(); + self.timer_initial = (self.timer_initial & 0x00FF) | ((timer_high as u16) << 8); + let length_index = (value & Length::LENGTH_COUNTER).bits() >> 3; + + if self.enabled { + self.length_counter = LENGTH_COUNTER_TABLE[length_index as usize]; + } + + self.sequencer = 0; + self.envelope.start = true; + } + + fn halted(&self) -> bool { + self.length_counter == 0 + } + + // Low-frequency clock to stop sound after a certain time + fn clock_length_counter(&mut self) { + if self.length_counter > 0 && !self.length_counter_halt { + self.length_counter -= 1; + } + } + + // High-frequency tick to control waveform generation + fn tick(&mut self) -> u8 { + let playing = !self.halted(); + let volume = self.envelope.volume(); + let waveform = PULSE_DUTY_WAVEFORM[self.duty_cycle as usize]; + let value = (waveform.rotate_right(self.sequencer as u32) & 0b1) * volume * playing as u8; + + if self.timer == 0 { + self.timer = self.timer_initial; + self.sequencer = self.sequencer.wrapping_add(1); + } else { + self.timer -= 1; + } + + value + } +} + +#[derive(Default)] +// An envelope changes a sound's volume over time. +// In the NES APU, it can set a constant volume or a decay. +struct Envelope { + constant_volume: bool, + looping: bool, + start: bool, + divider: u8, + decay_level: u8, + volume: u8, +} + +impl Envelope { + fn clock(&mut self) { + if !self.start { + self.clock_divider(); + } else { + self.start = false; + self.decay_level = 15; + self.divider = self.volume; + } + } + + fn clock_divider(&mut self) { + if self.divider == 0 { + self.divider = self.volume; + if self.decay_level > 0 { + self.decay_level -= 1; + } else if self.looping { + self.decay_level = 15; + } + } else { + self.divider -= 1; + } + } + + fn volume(&self) -> u8 { + if self.constant_volume { + self.volume + } else { + self.decay_level + } + } +} + +bitflags! { + struct Status: u8 { + const PULSE_1 = 0b0000_0001; + const PULSE_2 = 0b0000_0010; + const TRIANGLE = 0b0000_0100; + const NOISE = 0b0000_1000; + const DMC = 0b0001_0000; + const FRAME_INTERRUPT = 0b1000_0000; + const DMC_INTERRUPT = 0b1000_0000; + } + + #[derive(Copy, Clone)] + struct PulseFlags: u8 { + const DUTY = 0b1100_0000; + const LENGTH_COUNTER_HALT = 0b0010_0000; + const CONSTANT_VOLUME = 0b0001_0000; + const VOLUME = 0b0000_1111; + } + + #[derive(Copy, Clone)] + struct Length: u8 { + const LENGTH_COUNTER = 0b1111_1000; + const TIMER_HIGH = 0b0000_0111; + } + + struct FrameCounter: u8 { + const MODE = 0b1000_0000; + const IRQ_INHIBIT = 0b0100_0000; + } +} + +const PULSE_DUTY_WAVEFORM: [u8; 4] = [ + 0b00000010, // 12.5% duty cycle + 0b00000110, // 25% duty cycle + 0b00011110, // 50% duty cycle + 0b11111001, // 25% negated duty cycle +]; + +// I swear, there's a pattern here: +// https://www.nesdev.org/wiki/APU_Length_Counter +#[cfg_attr(any(), rustfmt::skip)] +const LENGTH_COUNTER_TABLE: [u8; 32] = [ + //⬇ Lengths for 90bpm ⬇ Linearly increasing lengths + 10, /* semiquaver */ 254, + 20, /* quaver */ 2, + 40, /* crotchet */ 4, + 80, /* minim */ 6, + 160, /* semibreve */ 8, + 60, /* dot. crotchet */ 10, + 14, /* trip. quaver */ 12, + 26, /* trip. crotchet */ 14, + //⬇ Lengths for 75bpm + 12, /* semiquaver */ 16, + 24, /* quaver */ 18, + 48, /* crotchet */ 20, + 96, /* minim */ 22, + 192, /* semibreve */ 24, + 72, /* dot. crotchet */ 26, + 16, /* trip. quaver */ 28, + 32, /* trip. crotchet */ 30, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pulse_generator_produces_rectangle_wave() { + let mut pulse = PulseGenerator { + enabled: true, + timer_initial: 8, + timer: 8, + sequencer: 0, + length_counter: 5, + length_counter_halt: false, + // Set duty to 25% and volume goes up to 11 + duty_cycle: 1, + envelope: Envelope { + constant_volume: true, + looping: false, + start: false, + divider: 0, + decay_level: 0, + volume: 11, + }, + }; + + // Get two periods of the waveform + let wave: Vec = std::iter::repeat_with(|| pulse.tick()) + .take(9 * 16) + .collect(); + + // Each part of wave is repeated `timer + 1 = 9` times + assert_eq!( + wave, + [ + vec![0; 9], + vec![11; 2 * 9], + vec![0; 6 * 9], + vec![11; 2 * 9], + vec![0; 5 * 9] + ] + .concat() + ); + } +} diff --git a/src/cpu/memory.rs b/src/cpu/memory.rs index 619bcc1..b4d96e4 100644 --- a/src/cpu/memory.rs +++ b/src/cpu/memory.rs @@ -3,6 +3,7 @@ use std::fmt::{Debug, Formatter}; use log::trace; +use crate::apu::APU; use crate::input::{Controller, Input}; use crate::ppu::{self, PPURegisters}; use crate::ArrayMemory; @@ -19,24 +20,46 @@ const PPU_SCROLL: Address = Address::new(0x2005); const PPU_ADDRESS: Address = Address::new(0x2006); const PPU_DATA: Address = Address::new(0x2007); const APU_SPACE: Address = Address::new(0x4000); +const APU_PULSE_1_FLAGS: Address = Address::new(0x4000); +const APU_PULSE_1_SWEEP: Address = Address::new(0x4001); +const APU_PULSE_1_TIMER: Address = Address::new(0x4002); +const APU_PULSE_1_LENGTH: Address = Address::new(0x4003); +const APU_PULSE_2_FLAGS: Address = Address::new(0x4004); +const APU_PULSE_2_SWEEP: Address = Address::new(0x4005); +const APU_PULSE_2_TIMER: Address = Address::new(0x4006); +const APU_PULSE_2_LENGTH: Address = Address::new(0x4007); +const APU_TRIANGLE_FLAGS: Address = Address::new(0x4008); +const APU_TRIANGLE_TIMER: Address = Address::new(0x400a); +const APU_TRIANGLE_LENGTH: Address = Address::new(0x400b); +const APU_NOISE_FLAGS: Address = Address::new(0x400c); +const APU_NOISE_PERIOD: Address = Address::new(0x400e); +const APU_NOISE_LENGTH: Address = Address::new(0x400f); +const APU_DMC_FLAGS: Address = Address::new(0x4010); +const APU_DMC_DIRECT_LOAD: Address = Address::new(0x4011); +const APU_DMC_SAMPLE_ADDRESS: Address = Address::new(0x4012); +const APU_DMC_SAMPLE_LENGTH: Address = Address::new(0x4013); const OAM_DMA: Address = Address::new(0x4014); +const APU_STATUS: Address = Address::new(0x4015); const JOY1_ADDRESS: Address = Address::new(0x4016); +const APU_FRAME_COUNTER: Address = Address::new(0x4017); const PRG_SPACE: Address = Address::new(0x4020); pub struct NESCPUMemory { internal_ram: [u8; 0x800], prg: PRG, ppu_registers: PPU, + apu: APU, input: IN, the_rest: ArrayMemory, // TODO } impl NESCPUMemory { - pub fn new(prg: PRG, ppu_registers: PPU, input: IN) -> Self { + pub fn new(prg: PRG, ppu_registers: PPU, apu: APU, input: IN) -> Self { NESCPUMemory { internal_ram: [0; 0x800], prg, ppu_registers, + apu, input, the_rest: ArrayMemory::default(), } @@ -46,6 +69,10 @@ impl NESCPUMemory { &mut self.ppu_registers } + pub fn apu(&mut self) -> &mut APU { + &mut self.apu + } + pub fn input(&mut self) -> &mut IN { &mut self.input } @@ -84,6 +111,8 @@ impl Memory for NESCPUMemory= APU_SPACE { self.the_rest.read(address) // TODO } else if address >= PPU_SPACE { @@ -108,7 +137,17 @@ impl Memory for NESCPUMemory= APU_SPACE { - self.the_rest.write(address, byte) // TODO + match address { + APU_PULSE_1_FLAGS => self.apu.write_pulse_1_flags(byte), + APU_PULSE_1_TIMER => self.apu.write_pulse_1_timer(byte), + APU_PULSE_1_LENGTH => self.apu.write_pulse_1_length(byte), + APU_PULSE_2_FLAGS => self.apu.write_pulse_2_flags(byte), + APU_PULSE_2_TIMER => self.apu.write_pulse_2_timer(byte), + APU_PULSE_2_LENGTH => self.apu.write_pulse_2_length(byte), + APU_FRAME_COUNTER => self.apu.write_frame_counter(byte), + APU_STATUS => self.apu.write_status(byte), + _ => self.the_rest.write(address, byte), // TODO + } } else if address >= PPU_SPACE { let mirrored = PPU_SPACE + (address.index() % 8) as u16; let ppu_registers = self.ppu_registers.borrow_mut(); @@ -395,6 +434,6 @@ mod tests { }; let prg = ArrayMemory::default(); let input = MockInput(0); - NESCPUMemory::new(prg, ppu, input) + NESCPUMemory::new(prg, ppu, APU::default(), input) } } diff --git a/src/lib.rs b/src/lib.rs index f4d3a7a..e8572aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,8 @@ use std::fmt::{Debug, Formatter}; +use apu::APU; + pub use crate::address::Address; pub use crate::cartridge::Cartridge; pub use crate::cpu::instructions; @@ -22,6 +24,7 @@ pub use crate::runtime::Runtime; pub use crate::serialize::SerializeByte; mod address; +mod apu; mod cartridge; mod cpu; mod i_nes; @@ -52,10 +55,7 @@ pub trait NESDisplay { fn enter_vblank(&mut self); } -#[derive(Debug)] -pub struct NoDisplay; - -impl NESDisplay for NoDisplay { +impl NESDisplay for () { fn draw_pixel(&mut self, _: Color) {} fn enter_vblank(&mut self) {} } @@ -125,22 +125,40 @@ impl NESDisplay for BufferDisplay { } } +pub trait NESSpeaker { + fn emit(&mut self, wave: u8); +} + +impl NESSpeaker for () { + fn emit(&mut self, _wave: u8) {} +} + #[derive(Debug)] -pub struct NES { +pub struct NES { cpu: CPU, display: D, + speaker: S, + // 2 CPU cycles = 1 APU cycle, so sometimes they don't perfectly line up and we need to keep track of the lag. + // e.g. if a CPU instruction takes 3 cycles, the APU will tick once but we have to remember to tick again after 1 CPU cycle next time. + apu_lag: u8, } -impl NES { - pub fn new(cartridge: Cartridge, display: D) -> Self { +impl NES { + pub fn new(cartridge: Cartridge, display: D, speaker: S) -> Self { let ppu_memory = NESPPUMemory::new(cartridge.chr); let ppu = PPU::with_memory(ppu_memory); let controller = Controller::default(); + let apu = APU::default(); - let cpu_memory = NESCPUMemory::new(cartridge.prg, ppu, controller); + let cpu_memory = NESCPUMemory::new(cartridge.prg, ppu, apu, controller); let cpu = CPU::from_memory(cpu_memory); - NES { cpu, display } + NES { + cpu, + display, + speaker, + apu_lag: 0, + } } pub fn display(&self) -> &D { @@ -164,16 +182,18 @@ impl NES { } pub fn tick(&mut self) { - let cpu_cycles = self.tick_cpu(); + let cpu_cycles = self.cpu.run_instruction(); // There are 3 PPU cycles to 1 CPU cycle for _ in 0..3 * cpu_cycles { self.tick_ppu(); } - } - fn tick_cpu(&mut self) -> u8 { - self.cpu.run_instruction() + let apu_cycles = (cpu_cycles + self.apu_lag) / 2; + for _ in 0..apu_cycles { + self.tick_apu(); + } + self.apu_lag = (cpu_cycles + self.apu_lag) % 2; } fn ppu(&mut self) -> &mut PPU { @@ -195,6 +215,12 @@ impl NES { self.display.enter_vblank(); } } + + fn tick_apu(&mut self) { + let apu = self.cpu.memory().apu(); + let wave = apu.tick(); + self.speaker.emit(wave); + } } #[macro_export] diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 72bb9a6..2427991 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -19,3 +19,5 @@ pub trait Runtime { const FPS: u64 = 60; const FRAME_DURATION: Duration = Duration::from_micros(1_000_000 / FPS); +const NES_AUDIO_FREQ: f64 = 894886.5; +const TARGET_AUDIO_FREQ: i32 = 44100; diff --git a/src/runtime/sdl.rs b/src/runtime/sdl.rs index 889afa7..7af31d2 100644 --- a/src/runtime/sdl.rs +++ b/src/runtime/sdl.rs @@ -1,9 +1,14 @@ use std::error::Error; use std::fs::File; +use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use std::time::Instant; use log::info; +use sdl2::audio::AudioCallback; +use sdl2::audio::AudioDevice; +use sdl2::audio::AudioSpecDesired; use sdl2::event::Event; use sdl2::keyboard::Keycode; use sdl2::render::WindowCanvas; @@ -12,11 +17,14 @@ use sdl2::video::WindowContext; use crate::INes; use crate::NESDisplay; +use crate::NESSpeaker; use crate::NES; use crate::{Buttons, Color, HEIGHT, WIDTH}; use super::Runtime; use super::FRAME_DURATION; +use super::NES_AUDIO_FREQ; +use super::TARGET_AUDIO_FREQ; const SCALE: u16 = 3; @@ -63,6 +71,7 @@ impl Runtime for Sdl { let texture_creator = canvas.texture_creator(); let display = SDLDisplay::new(&texture_creator, canvas); + let speaker = SDLSpeaker::new(&sdl_context)?; let args: Vec = std::env::args().collect(); @@ -77,7 +86,7 @@ impl Runtime for Sdl { let cartridge = ines.into_cartridge(); - let mut nes = NES::new(cartridge, display); + let mut nes = NES::new(cartridge, display, speaker); loop { // Arbitrary number of ticks so we don't poll events too much @@ -179,19 +188,25 @@ impl<'r> NESDisplay for SDLDisplay<'r> { self.canvas.copy(&self.texture, None, None).unwrap(); self.canvas.present(); - let elapsed = self.start_of_frame.elapsed(); - let time_to_sleep = FRAME_DURATION.checked_sub(elapsed).unwrap_or_default(); - std::thread::sleep(time_to_sleep); - - self.start_of_frame = Instant::now(); + let now = Instant::now(); + let elapsed = now.duration_since(self.start_of_frame); + if let Some(time_to_sleep) = FRAME_DURATION.checked_sub(elapsed) { + std::thread::sleep(time_to_sleep); + self.start_of_frame = now + time_to_sleep; + } else { + // We're running behind, sleep less next time + self.start_of_frame = now - (elapsed - FRAME_DURATION); + } self.frames_since_last_fps_log += 1; - let elapsed_since_last_fps_log = self.last_fps_log.elapsed(); + let now = Instant::now(); + let elapsed_since_last_fps_log = now.duration_since(self.last_fps_log); if elapsed_since_last_fps_log > Duration::from_secs(5) { - let fps = self.frames_since_last_fps_log / elapsed_since_last_fps_log.as_secs(); + let fps = self.frames_since_last_fps_log as f64 + / elapsed_since_last_fps_log.as_secs_f64(); info!("FPS: {}", fps); - self.last_fps_log = Instant::now(); + self.last_fps_log = now; self.frames_since_last_fps_log = 0; } } @@ -199,3 +214,93 @@ impl<'r> NESDisplay for SDLDisplay<'r> { fn enter_vblank(&mut self) {} } + +struct SDLSpeaker { + _device: AudioDevice, + buffer: AudioBuffer, + next_sample: f64, +} + +impl SDLSpeaker { + fn new(sdl_context: &sdl2::Sdl) -> Result { + let audio_subsystem = sdl_context.audio()?; + + let desired_spec = AudioSpecDesired { + freq: Some(TARGET_AUDIO_FREQ), + channels: Some(1), + samples: None, + }; + + let double_buffer = Arc::new(Mutex::new(Vec::new())); + + let device = audio_subsystem.open_playback(None, &desired_spec, |spec| { + let double_buffer = double_buffer.clone(); + double_buffer + .lock() + .unwrap() + .resize(spec.samples as usize, 0); + MyAudioCallback(double_buffer) + })?; + device.resume(); + + let sample_size = device.spec().samples; + log::info!("Audio sample size: {}", sample_size); + + let buffer = AudioBuffer::new(sample_size as usize, double_buffer); + + Ok(Self { + _device: device, + buffer, + next_sample: 0.0, + }) + } +} + +impl NESSpeaker for SDLSpeaker { + fn emit(&mut self, value: u8) { + // Naive downsampling + if self.next_sample <= 0.0 { + self.buffer.push(value); + self.next_sample += NES_AUDIO_FREQ / TARGET_AUDIO_FREQ as f64; + } + self.next_sample -= 1.0; + } +} + +struct AudioBuffer { + size: usize, + buffer: Vec, + double_buffer: Arc>>, +} + +impl AudioBuffer { + fn new(size: usize, double_buffer: Arc>>) -> Self { + let buffer = Vec::with_capacity(size); + Self { + size, + buffer, + double_buffer, + } + } + + fn push(&mut self, value: u8) { + self.buffer.push(value); + if self.buffer.len() == self.size { + let mut double_buffer = self.double_buffer.lock().unwrap(); + double_buffer.copy_from_slice(&self.buffer); + self.buffer.clear(); + } + } +} + +struct MyAudioCallback(Arc>>); + +impl AudioCallback for MyAudioCallback { + type Channel = u8; + + fn callback(&mut self, out: &mut [u8]) { + let buffer = self.0.lock().unwrap(); + debug_assert_eq!(buffer.len(), out.len()); + out.copy_from_slice(&buffer); + } +} diff --git a/src/runtime/web.rs b/src/runtime/web.rs index 841703d..7a5fcd6 100644 --- a/src/runtime/web.rs +++ b/src/runtime/web.rs @@ -191,7 +191,7 @@ impl Runtime for Web { } struct NesContext { - nes: NES, + nes: NES, rom_hash: u64, } @@ -268,13 +268,13 @@ fn set_rom(rom: &[u8]) -> Result> { rom.hash(&mut rom_hasher); let rom_hash = rom_hasher.finish(); - let mut nes = NES::new(cartridge, display); + let mut nes = NES::new(cartridge, display, ()); load_state(rom_hash, &mut nes)?; Ok(NesContext { nes, rom_hash }) } -fn save_state(rom_hash: u64, nes: &mut NES) -> Result<(), Box> { +fn save_state(rom_hash: u64, nes: &mut NES) -> Result<(), Box> { let ram = nes.cpu.memory().prg().ram(); let key = state_key(rom_hash); @@ -285,7 +285,7 @@ fn save_state(rom_hash: u64, nes: &mut NES) -> Result<(), Box> Ok(()) } -fn load_state(rom_hash: u64, nes: &mut NES) -> Result<(), Box> { +fn load_state(rom_hash: u64, nes: &mut NES) -> Result<(), Box> { let key = state_key(rom_hash); let value = match local_storage()? .get_item(&key) diff --git a/tests/external_tests.rs b/tests/external_tests.rs index 64319c8..200d439 100644 --- a/tests/external_tests.rs +++ b/tests/external_tests.rs @@ -176,7 +176,7 @@ fn external_test( let ines = INes::read(cursor).unwrap(); let cartridge = ines.into_cartridge(); - let mut nes = NES::new(cartridge, BufferDisplay::default()); + let mut nes = NES::new(cartridge, BufferDisplay::default(), ()); match setup { Setup::Default => {} @@ -222,7 +222,7 @@ fn external_test( ); } -fn get_result(success_check: Success, nes: &mut NES) -> Result<(), String> { +fn get_result(success_check: Success, nes: &mut NES) -> Result<(), String> { match success_check { Success::Never => Err("Always fails".to_owned()), Success::Screen(bytes) => { @@ -260,7 +260,7 @@ fn clear_nes_test_result_image(name: &str) { let _ = fs::remove_file(&fname); } -fn save_nes_test_result_image(name: &str, nes: &NES) -> String { +fn save_nes_test_result_image(name: &str, nes: &NES) -> String { let fname = nes_test_result_image_name(name); let buffer = nes.display().buffer(); image::save_buffer(