neser 1.2.0

NESER - Nintendo Emulation Systems Engine (Rust). Desktop and WebAssembly frontends.
Documentation
use neser::gba::Gba;
use neser::platform::app_context::AppContext;
use neser::platform::config::Config;
use neser::platform::emulator::Emulator;
use neser::platform::frame_benchmark::{
    FrameBenchmarkConfig, FrameBenchmarkConfigError, FrameTimingStats,
};
use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};

fn main() {
    if let Err(err) = run(std::env::args().collect()) {
        eprintln!("{err}");
        eprintln!("{}", usage());
        std::process::exit(2);
    }
}

fn run(args: Vec<String>) -> Result<(), String> {
    let bench_config = FrameBenchmarkConfig::parse_args(&args).map_err(config_error_message)?;
    let rom_data = std::fs::read(&bench_config.rom_path)
        .map_err(|err| format!("failed to read ROM '{}': {err}", bench_config.rom_path))?;

    let mut gba = create_loaded_gba(&rom_data, &bench_config)?;
    for _ in 0..bench_config.warmup_frames {
        run_one_frame(&mut gba);
    }

    let samples = run_measured_frames(&mut gba, bench_config.frames);
    let stats = FrameTimingStats::from_samples(&samples)
        .map_err(|err| format!("failed to compute benchmark stats: {err:?}"))?;

    print_summary("Primary run", &bench_config, &stats);

    if bench_config.stability_runs > 0 {
        println!(
            "\nStability check ({} run(s) of {} frames):",
            bench_config.stability_runs, bench_config.frames
        );
        for run_index in 1..=bench_config.stability_runs {
            let samples = if bench_config.reset_stability_runs {
                run_fresh_benchmark(&rom_data, &bench_config)?
            } else {
                run_measured_frames(&mut gba, bench_config.frames)
            };
            let stats = FrameTimingStats::from_samples(&samples)
                .map_err(|err| format!("failed to compute benchmark stats: {err:?}"))?;
            println!(
                "  Run {run_index}: avg={:.3}ms p95={:.3}ms max={:.3}ms fps={:.1}",
                stats.average_ms, stats.p95_ms, stats.max_ms, stats.fps
            );
        }
    }

    Ok(())
}

fn create_loaded_gba(rom_data: &[u8], bench_config: &FrameBenchmarkConfig) -> Result<Gba, String> {
    let mut gba = create_gba(bench_config.skip_gba_bios_intro);
    gba.load_rom(rom_data, &bench_config.rom_path)
        .map_err(|err| format!("failed to load GBA ROM '{}': {err}", bench_config.rom_path))?;
    gba.set_audio_sample_rate(44_100.0);
    Ok(gba)
}

fn create_gba(skip_bios_intro: bool) -> Gba {
    let mut config = Config::default();
    config.gba.bios_path = Some("embedded".to_string());
    config.gba.skip_bios_intro = skip_bios_intro;
    let app_context = Rc::new(RefCell::new(AppContext::new_with_config(config)));
    Gba::new(app_context)
}

fn run_fresh_benchmark(
    rom_data: &[u8],
    bench_config: &FrameBenchmarkConfig,
) -> Result<Vec<Duration>, String> {
    let mut gba = create_loaded_gba(rom_data, bench_config)?;
    for _ in 0..bench_config.warmup_frames {
        run_one_frame(&mut gba);
    }
    Ok(run_measured_frames(&mut gba, bench_config.frames))
}

fn run_measured_frames(gba: &mut Gba, frames: usize) -> Vec<Duration> {
    let mut samples = Vec::with_capacity(frames);
    for _ in 0..frames {
        let start = Instant::now();
        run_one_frame(gba);
        samples.push(start.elapsed());
    }
    samples
}

fn run_one_frame(gba: &mut Gba) {
    while !gba.is_ready_to_render() {
        let _cycles = gba.run_tick();
        while gba.get_stereo_sample().is_some() {}
    }
    gba.clear_ready_to_render();
}

fn print_summary(label: &str, bench_config: &FrameBenchmarkConfig, stats: &FrameTimingStats) {
    println!("{label}");
    println!("ROM: {}", bench_config.rom_path);
    println!("Frames: {}", stats.frames);
    println!("Warmup frames: {}", bench_config.warmup_frames);
    println!("Skip GBA BIOS intro: {}", bench_config.skip_gba_bios_intro);
    println!(
        "Reset stability runs: {}",
        bench_config.reset_stability_runs
    );
    println!("Total: {:.1}ms", stats.total.as_secs_f64() * 1000.0);
    println!("Average: {:.3}ms/frame", stats.average_ms);
    println!("P50: {:.3}ms/frame", stats.p50_ms);
    println!("P95: {:.3}ms/frame", stats.p95_ms);
    println!("Max: {:.3}ms/frame", stats.max_ms);
    println!("FPS: {:.1}", stats.fps);
}

fn usage() -> &'static str {
    "Usage: gba_frame_bench <rom_path> [--frames N] [--warmup N] [--stability-runs N] [--skip-bios-intro|--include-bios-intro] [--continue-stability-runs]\n\
\n\
Benchmarks GBA frame execution using the embedded BIOS. By default the GBA BIOS intro is skipped.\n\
Stability runs reload the ROM by default so each run measures the same title/intro window."
}

fn config_error_message(err: FrameBenchmarkConfigError) -> String {
    match err {
        FrameBenchmarkConfigError::MissingRomPath => "missing ROM path".to_string(),
        FrameBenchmarkConfigError::MissingValue { name } => {
            format!("missing value for --{name}")
        }
        FrameBenchmarkConfigError::InvalidNumber { name, value } => {
            format!("invalid value for --{name}: '{value}'")
        }
        FrameBenchmarkConfigError::UnknownArgument(arg) => {
            format!("unknown argument: {arg}")
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn usage_documents_primary_gba_benchmark_options() {
        let usage = usage();

        assert!(usage.contains("Usage: gba_frame_bench <rom_path>"));
        assert!(usage.contains("--frames"));
        assert!(usage.contains("--warmup"));
        assert!(usage.contains("--stability-runs"));
        assert!(usage.contains("--skip-bios-intro"));
        assert!(usage.contains("--include-bios-intro"));
        assert!(usage.contains("--continue-stability-runs"));
    }
}