neser 0.1.0

NESER - NES Emulator in Rust - is a NES emulator written in Rust. It aims to be a high-quality, hardware-accurate emulator that is also easy to use and extend. It supports a wide range of NES games and features, including various mappers, audio processing, and input handling. NESER is designed to be modular and extensible, allowing developers to easily add new features or support for additional hardware. It can be run using one of two frontends: a native desktop application using SDL2, or a web application using WebAssembly. The desktop application provides a high-performance, feature-rich experience with support for various input devices and display options, while the web application allows users to play NES games directly in their browsers without needing to install any software in a BYOR manner (Bring Your Own Roms).
Documentation
#[cfg(test)]
pub(crate) mod tests {
    use crate::cartridge::Cartridge;
    use crate::console::{Config, ExpansionPort, HardwareMode, Nes, RamInitMode};
    use crate::input::{Button, ControllerType, SnesButton};
    use crate::integration_tests::rom_test_runner::tests::run_nes_for_frames;

    /// Controller configuration for a test scenario.
    #[derive(Debug, Clone)]
    #[allow(dead_code)]
    pub(crate) struct ControllerConfig {
        pub port1: ControllerType,
        pub port2: ControllerType,
        pub hardware_mode: Option<HardwareMode>,
        pub expansion_port: Option<ExpansionPort>,
    }

    #[allow(dead_code)]
    impl ControllerConfig {
        pub fn to_config(&self) -> Config {
            let mut config = Config {
                ram_init_mode: RamInitMode::Zero,
                controller_port1: self.port1,
                controller_port2: self.port2,
                controller_port1_explicit: true,
                controller_port2_explicit: true,
                ..Default::default()
            };
            if let Some(hw_mode) = self.hardware_mode {
                config.hardware_mode = hw_mode;
                config.hardware_mode_explicit = true;
            }
            if let Some(exp_port) = self.expansion_port {
                config.expansion_port = exp_port;
                config.expansion_port_explicit = true;
            }
            config
        }

        pub fn joypad_port1() -> Self {
            Self {
                port1: ControllerType::Joypad,
                port2: ControllerType::Joypad,
                hardware_mode: None,
                expansion_port: None,
            }
        }

        pub fn snes_controller_port1() -> Self {
            Self {
                port1: ControllerType::SnesController,
                port2: ControllerType::SnesController,
                hardware_mode: None,
                expansion_port: None,
            }
        }

        pub fn snes_mouse_port1() -> Self {
            Self {
                port1: ControllerType::SnesMouse,
                port2: ControllerType::SnesMouse,
                hardware_mode: None,
                expansion_port: None,
            }
        }

        pub fn zapper() -> Self {
            Self {
                port1: ControllerType::Joypad,
                port2: ControllerType::Zapper,
                hardware_mode: None,
                expansion_port: None,
            }
        }

        pub fn famicom_joypad() -> Self {
            Self {
                port1: ControllerType::Joypad,
                port2: ControllerType::Joypad,
                hardware_mode: Some(HardwareMode::Famicom),
                expansion_port: None,
            }
        }

        pub fn arkanoid() -> Self {
            Self {
                port1: ControllerType::Arkanoid,
                port2: ControllerType::Joypad,
                hardware_mode: None,
                expansion_port: None,
            }
        }

        pub fn arkanoid_port2() -> Self {
            Self {
                port1: ControllerType::Joypad,
                port2: ControllerType::Arkanoid,
                hardware_mode: None,
                expansion_port: None,
            }
        }

        pub fn arkanoid_famicom_expansion() -> Self {
            Self {
                port1: ControllerType::Joypad,
                port2: ControllerType::Joypad,
                hardware_mode: Some(HardwareMode::Famicom),
                expansion_port: Some(ExpansionPort::ArkanoidFamicom),
            }
        }
    }

    /// A single input action to apply at a specific frame.
    #[derive(Debug, Clone)]
    #[allow(dead_code)]
    pub(crate) enum InputAction {
        /// Press or release a joypad button on a port.
        Button {
            port: u8,
            button: Button,
            pressed: bool,
        },
        /// Press or release an SNES button on a port.
        SnesButton {
            port: u8,
            button: SnesButton,
            pressed: bool,
        },
        /// Set the mouse X position (for Arkanoid/Zapper).
        MouseX(u8),
        /// Set the mouse Y position (for Zapper).
        MouseY(u8),
        /// Set mouse left button (for Arkanoid trigger / Zapper trigger).
        MouseButton(bool),
        /// Set mouse right button (for Super NES mouse secondary button).
        MouseRightButton(bool),
    }

    /// A scripted input entry: apply actions at a specific frame.
    #[derive(Debug, Clone)]
    pub(crate) struct ScriptEntry {
        pub frame: u32,
        pub actions: Vec<InputAction>,
    }

    /// Captured output from a single frame snapshot.
    #[derive(Debug, Clone)]
    #[allow(dead_code)]
    pub(crate) struct FrameCapture {
        pub frame: u32,
        pub nametable_text: String,
        pub nametable_raw: Vec<u8>,
        pub oam_data: Vec<u8>,
    }

    /// Result of running a ROM test harness.
    #[derive(Debug, Clone)]
    pub(crate) struct RomTestResult {
        pub captures: Vec<FrameCapture>,
    }

    /// Run any ROM with the given config and frame script, using a custom
    /// tile-to-char function for nametable text conversion.
    pub(crate) fn run_rom_with_script(
        rom_path: &str,
        config: &Config,
        script: &[ScriptEntry],
        total_frames: u32,
        capture_interval: u32,
        tile_to_char: impl Fn(u8) -> char,
    ) -> RomTestResult {
        let rom_data =
            std::fs::read(rom_path).unwrap_or_else(|_| panic!("{rom_path} ROM should be readable"));
        let cartridge =
            Cartridge::load_from_file(&rom_data, rom_path, crate::app_context::AppContext::new())
                .unwrap_or_else(|_| panic!("{rom_path} ROM should parse successfully"));

        let mut nes = Nes::new(crate::app_context::AppContext::new_with_config(
            config.clone(),
        ));
        nes.insert_cartridge(cartridge);
        nes.reset(false);

        let mut captures = Vec::new();
        let mut script_idx = 0;

        for frame in 1..=total_frames {
            // Apply any scripted actions for this frame
            while script_idx < script.len() && script[script_idx].frame == frame {
                for action in &script[script_idx].actions {
                    match action {
                        InputAction::Button {
                            port,
                            button,
                            pressed,
                        } => {
                            nes.set_button(*port, *button, *pressed);
                        }
                        InputAction::SnesButton {
                            port,
                            button,
                            pressed,
                        } => {
                            nes.set_snes_button(*port, *button, *pressed);
                        }
                        InputAction::MouseX(pos) => {
                            nes.set_mouse_x_position(*pos);
                        }
                        InputAction::MouseY(pos) => {
                            nes.set_mouse_y_position(*pos);
                        }
                        InputAction::MouseButton(pressed) => {
                            nes.set_mouse_left_button(*pressed);
                        }
                        InputAction::MouseRightButton(pressed) => {
                            nes.set_mouse_right_button(*pressed);
                        }
                    }
                }
                script_idx += 1;
            }

            // Run one frame
            run_nes_for_frames(&mut nes, 1);

            // Capture nametable text at the requested interval or at the final frame
            let should_capture = if capture_interval > 0 {
                frame % capture_interval == 0
            } else {
                frame == total_frames
            };

            if should_capture {
                let base_addr = nes.base_nametable_addr();
                let nametable_raw = nes.read_nametable_raw(base_addr, 32 * 30);
                let nametable_text = nametable_raw
                    .chunks(32)
                    .map(|chunk| {
                        chunk
                            .iter()
                            .map(|&b| tile_to_char(b))
                            .collect::<String>()
                            .trim_end()
                            .to_string()
                    })
                    .filter(|s| !s.is_empty())
                    .collect::<Vec<_>>()
                    .join("\n");

                let oam_data = nes.ppu().borrow().oam_snapshot();

                captures.push(FrameCapture {
                    frame,
                    nametable_text,
                    nametable_raw,
                    oam_data,
                });
            }
        }

        RomTestResult { captures }
    }
}