#![allow(dead_code)]
mod nes;
mod frontends;
mod gb;
mod gba;
mod platform;
use nes::console::{
CartridgeCatalogOptions, Config, Nes, ParseResult, default_catalog_csv_path,
refresh_cartridge_catalog,
};
use platform::app_context::AppContext;
use platform::autorun::AutorunFormat;
use platform::debugging::log_info;
use platform::frontend_toasts::cartridge_load_toast_message;
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
use std::rc::Rc;
fn cartridge_catalog_startup_config(
app_context: &Rc<RefCell<AppContext>>,
) -> (Vec<String>, bool, bool) {
let config = app_context.borrow();
let config = config.config();
(
config.frontend.cartridge_search_paths.clone(),
config.frontend.scan_cartridges,
config.frontend.rebuild_cartridge_catalog,
)
}
fn refresh_startup_cartridge_catalog(app_context: &Rc<RefCell<AppContext>>) {
let (cartridge_search_paths, scan_cartridges, rebuild_cartridge_catalog) =
cartridge_catalog_startup_config(app_context);
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(home);
let catalog_path = default_catalog_csv_path(home_path.as_path());
let mut search_paths: Vec<PathBuf> = cartridge_search_paths
.into_iter()
.map(PathBuf::from)
.collect();
if search_paths.is_empty() {
search_paths.push(home_path.join(".neser").join("roms"));
}
let mut catalog_options = CartridgeCatalogOptions::new(search_paths, catalog_path);
catalog_options.scan_enabled = scan_cartridges;
catalog_options.rebuild_catalog = rebuild_cartridge_catalog;
if let Err(err) = refresh_cartridge_catalog(&catalog_options) {
log_info(format!(
"Warning: failed to refresh cartridge catalog: {err}"
));
}
}
}
fn convert_autorun_for_rom(rom_path: &str, format: AutorunFormat) -> Result<String, String> {
use platform::autorun::{AUTORUN_VERSION, autorun_path_for_rom, convert_autorun_file};
let path = autorun_path_for_rom(&PathBuf::from(rom_path));
if !path.exists() {
return Err(format!(
"No autorun file found for ROM {}: {}",
rom_path,
path.display()
));
}
convert_autorun_file(&path, format, None)?;
Ok(format!(
"Converted autorun file to {} format (version {}): {}",
format,
AUTORUN_VERSION,
path.display()
))
}
fn trim_autorun_checkpoints_for_rom(
rom_path: &str,
checkpoints_to_trim: usize,
format: AutorunFormat,
) -> Result<String, String> {
use platform::autorun::{
autorun_path_for_rom, load_autorun_file, save_autorun_file, trim_recording,
};
use std::path::PathBuf;
let path = autorun_path_for_rom(&PathBuf::from(rom_path));
let mut file = load_autorun_file(&path, None)?;
let checkpoints_before = file.checkpoints.len();
trim_recording(&mut file, checkpoints_to_trim);
save_autorun_file(&path, &file, format, None)?;
Ok(format!(
"Trimmed {} checkpoint(s): {} → {} checkpoints, {} frames remaining",
checkpoints_before.saturating_sub(file.checkpoints.len()),
checkpoints_before,
file.checkpoints.len(),
file.frames.len(),
))
}
fn recalculate_autorun_for_rom(rom_path: &str, format: AutorunFormat) -> Result<String, String> {
use nes::autorun::headless_playback::recalculate_checkpoint_crcs_with_progress;
use nes::cartridge::Cartridge;
use nes::console::RamInitMode;
use platform::autorun::{autorun_path_for_rom, load_autorun_file, save_autorun_file};
use platform::config::FrontendConfig;
use std::io::{self, Write};
let path = autorun_path_for_rom(&PathBuf::from(rom_path));
if !path.exists() {
return Err(format!(
"No autorun file found for ROM {}: {}",
rom_path,
path.display()
));
}
let mut file = load_autorun_file(&path, None)?;
let rom_bytes =
fs::read(rom_path).map_err(|e| format!("Failed to read ROM {}: {e}", rom_path))?;
let config = Config {
frontend: FrontendConfig {
ram_init_mode: RamInitMode::Zero,
..Default::default()
},
..Default::default()
};
let app_context = AppContext::new_with_config(config);
let mut nes = Nes::new(app_context);
let cart = Cartridge::load_from_file(&rom_bytes, rom_path, Some(nes.rom_db()))
.map_err(|e| format!("Failed to load cartridge {}: {e}", rom_path))?;
nes.insert_cartridge(cart);
nes.reset(false);
let mut progress_printed = false;
let updated =
recalculate_checkpoint_crcs_with_progress(&mut nes, &mut file, None, |done, total| {
progress_printed = true;
print!("\rRecalculating checkpoint CRC(s): {done}/{total}");
let _ = io::stdout().flush();
})?;
if progress_printed {
println!("\n");
}
save_autorun_file(&path, &file, format, None)?;
Ok(format!(
"Recalculated {} checkpoint CRC(s) in {}",
updated,
path.display()
))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let parsed_config = match Config::new(&args)? {
ParseResult::Help => {
Config::print_help();
return Ok(());
}
ParseResult::Version => {
println!("neser {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
ParseResult::Config(c) => *c,
};
let app_context = Rc::new(RefCell::new(AppContext::new_with_config(parsed_config)));
#[cfg(feature = "tui")]
if app_context.borrow().config().frontend.tui_mode {
let (search_paths, _, rebuild) = cartridge_catalog_startup_config(&app_context);
let include_unofficial = app_context
.borrow()
.config()
.frontend
.include_unofficial_roms;
return frontends::tui::run_tui(&search_paths, rebuild, include_unofficial);
}
refresh_startup_cartridge_catalog(&app_context);
let trim_checkpoints = app_context
.borrow()
.config()
.frontend
.autorun_trim_checkpoints;
let trim_rom_path = app_context.borrow().config().frontend.rom_path.clone();
let trim_format = app_context.borrow().config().frontend.autorun_format;
if let (Some(checkpoints_to_trim), Some(rom_path)) =
(trim_checkpoints, trim_rom_path.as_deref())
{
let message = trim_autorun_checkpoints_for_rom(rom_path, checkpoints_to_trim, trim_format)?;
println!("{message}");
return Ok(());
}
let convert_autorun_requested = app_context.borrow().config().frontend.autorun_convert;
let convert_rom_path = app_context.borrow().config().frontend.rom_path.clone();
let convert_format = app_context.borrow().config().frontend.autorun_format;
if convert_autorun_requested {
let rom_path =
convert_rom_path.ok_or_else(|| "--convert-autorun requires a ROM path".to_string())?;
let message = convert_autorun_for_rom(&rom_path, convert_format)?;
println!("{message}");
return Ok(());
}
let recalculate_autorun_requested = app_context.borrow().config().frontend.autorun_recalculate;
let recalculate_rom_path = app_context.borrow().config().frontend.rom_path.clone();
let recalculate_format = app_context.borrow().config().frontend.autorun_format;
if recalculate_autorun_requested {
let rom_path = recalculate_rom_path
.ok_or_else(|| "--recalculate-autorun requires a ROM path".to_string())?;
let message = recalculate_autorun_for_rom(&rom_path, recalculate_format)?;
println!("{message}");
return Ok(());
}
let tracing_config = app_context.borrow().config().frontend.tracing;
platform::debugging::init_tracing(tracing_config);
#[cfg(feature = "native")]
{
run_native_frontend(app_context)?;
}
#[cfg(not(feature = "native"))]
{
eprintln!("No frontend feature enabled. Enable the 'native' feature.");
std::process::exit(1);
}
Ok(())
}
#[cfg(feature = "native")]
fn run_native_frontend(
app_context: Rc<RefCell<AppContext>>,
) -> Result<(), Box<dyn std::error::Error>> {
use frontends::native::rom_browser::{BrowserResult, RomBrowserApp};
use winit::event_loop::EventLoop;
let rom_path = app_context.borrow().config().frontend.rom_path.clone();
if let Some(rom_path) = rom_path {
run_native_emulator(app_context, &rom_path, None)
} else {
let mut event_loop =
EventLoop::new().map_err(|e| format!("Failed to create event loop: {e}"))?;
let mut browser = RomBrowserApp::new(app_context.clone());
loop {
match browser.run(&mut event_loop)? {
BrowserResult::RomSelected(path) => {
let rom_path = path.to_string_lossy().to_string();
if let Err(e) =
run_native_emulator(app_context.clone(), &rom_path, Some(&mut event_loop))
{
crate::platform::debugging::log_info(format!("Emulator error: {e}"));
}
}
BrowserResult::Closed => return Ok(()),
}
}
}
}
#[cfg(feature = "native")]
fn run_native_emulator(
app_context: Rc<RefCell<AppContext>>,
rom_path: &str,
event_loop: Option<&mut winit::event_loop::EventLoop<()>>,
) -> Result<(), Box<dyn std::error::Error>> {
use frontends::native::{NativeAudio, NativeEventLoop};
use platform::audio::EmulatorAudio;
let (
autorun_mode,
autorun_headless,
autorun_overwrite,
autorun_extend,
autorun_from_checkpoint,
autorun_format,
) = {
let config = app_context.borrow();
let config = config.config();
(
config.frontend.autorun_mode,
config.frontend.autorun_headless,
config.frontend.autorun_overwrite,
config.frontend.autorun_extend,
config.frontend.autorun_from_checkpoint,
config.frontend.autorun_format,
)
};
let headless = autorun_headless && autorun_mode == platform::autorun::AutorunMode::Playback;
let mut audio_sample_rate = None;
let (audio_enabled, audio_buffer_ms, configured_sample_rate) = {
let config = app_context.borrow();
let frontend = &config.config().frontend;
(
frontend.audio_enabled,
frontend.audio_buffer_ms,
frontend.audio_sample_rate,
)
};
let audio = if !audio_enabled || headless {
None
} else {
let audio = NativeAudio::new(configured_sample_rate as i32, audio_buffer_ms)?;
audio_sample_rate = Some(audio.actual_sample_rate() as f32);
Some(audio)
};
let rom_bytes = match fs::read(rom_path) {
Ok(bytes) => bytes,
Err(err) => {
app_context
.borrow_mut()
.add_toast(cartridge_load_toast_message(rom_path, false));
return Err(err.into());
}
};
let console = match detect_system_type(rom_path) {
platform::emulator::SystemType::Nes => {
let rom_db = nes::cartridge::load_rom_db();
let cart = match nes::cartridge::Cartridge::load_from_file(
&rom_bytes,
rom_path,
Some(&rom_db),
) {
Ok(cartridge) => {
app_context
.borrow_mut()
.add_toast(cartridge_load_toast_message(rom_path, true));
cartridge
}
Err(err) => {
app_context
.borrow_mut()
.add_toast(cartridge_load_toast_message(rom_path, false));
return Err(err.into());
}
};
let rom_timing_mode = cart.rom_timing_mode();
app_context
.borrow_mut()
.config_mut()
.apply_rom_timing_mode(rom_timing_mode);
let mut console = platform::emulator::Console::new_nes(app_context.clone());
{
let platform::emulator::Console::Nes(nes) = &mut console else {
panic!("expected NES console")
};
nes.insert_cartridge(cart);
}
console
}
platform::emulator::SystemType::GameBoy => {
let mut console = platform::emulator::Console::new_gameboy(app_context.clone());
if let Err(err) = console.load_rom(&rom_bytes, rom_path) {
app_context
.borrow_mut()
.add_toast(cartridge_load_toast_message(rom_path, false));
return Err(err.into());
}
app_context
.borrow_mut()
.add_toast(cartridge_load_toast_message(rom_path, true));
console
}
platform::emulator::SystemType::Gba => {
let mut console = platform::emulator::Console::new_gba(app_context.clone());
if let Err(err) = console.load_rom(&rom_bytes, rom_path) {
app_context
.borrow_mut()
.add_toast(cartridge_load_toast_message(rom_path, false));
return Err(err.into());
}
app_context
.borrow_mut()
.add_toast(cartridge_load_toast_message(rom_path, true));
console
}
};
let mut console = console;
if let Some(actual_rate) = audio_sample_rate {
console.set_audio_sample_rate(actual_rate);
}
console.reset(false);
let tracing = app_context.borrow().config().frontend.tracing;
let mut native_loop =
NativeEventLoop::new(app_context.clone(), console, audio, tracing, headless);
if autorun_mode != platform::autorun::AutorunMode::None {
native_loop.init_autorun(
autorun_mode,
rom_path,
autorun_overwrite,
autorun_extend,
autorun_from_checkpoint,
autorun_format,
)?;
}
let run_result = if let Some(el) = event_loop {
native_loop.run_with_event_loop(el)
} else {
native_loop.run()
};
if let Err(ref e) = run_result
&& let Some(exit_code) = e
.strip_prefix("AUTORUN_EXIT:")
.and_then(|s| s.parse::<i32>().ok())
{
std::process::exit(exit_code);
}
run_result.map_err(|e| e.into())
}
fn detect_system_type(path: &str) -> platform::emulator::SystemType {
use std::path::Path;
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if ext.eq_ignore_ascii_case("gb") || ext.eq_ignore_ascii_case("gbc") {
platform::emulator::SystemType::GameBoy
} else if ext.eq_ignore_ascii_case("gba") {
platform::emulator::SystemType::Gba
} else {
platform::emulator::SystemType::Nes
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::autorun::AUTORUN_VERSION;
use crate::platform::emulator::SystemType;
use tempfile::TempDir;
#[test]
fn detect_system_type_gb_extension_returns_gameboy() {
assert_eq!(detect_system_type("tetris.gb"), SystemType::GameBoy);
}
#[test]
fn detect_system_type_gbc_extension_returns_gameboy() {
assert_eq!(detect_system_type("game.gbc"), SystemType::GameBoy);
}
#[test]
fn detect_system_type_uppercase_gbc_returns_gameboy() {
assert_eq!(detect_system_type("GAME.GBC"), SystemType::GameBoy);
}
#[test]
fn detect_system_type_nes_extension_returns_nes() {
assert_eq!(detect_system_type("cpu.nes"), SystemType::Nes);
}
#[test]
fn detect_system_type_uppercase_gb_returns_gameboy() {
assert_eq!(detect_system_type("TETRIS.GB"), SystemType::GameBoy);
}
#[test]
fn detect_system_type_gba_extension_returns_gba() {
assert_eq!(detect_system_type("zelda.gba"), SystemType::Gba);
}
#[test]
fn detect_system_type_unknown_extension_falls_back_to_nes() {
assert_eq!(detect_system_type("rom.unknown"), SystemType::Nes);
}
#[test]
fn detect_system_type_no_extension_falls_back_to_nes() {
assert_eq!(detect_system_type("noext"), SystemType::Nes);
}
#[test]
fn test_convert_autorun_for_rom_fails_when_autorun_file_missing() {
let temp_dir = TempDir::new().expect("create temp dir");
let rom_path = temp_dir.path().join("missing.nes");
let result = convert_autorun_for_rom(
rom_path.to_str().expect("rom path to str"),
AutorunFormat::default(),
);
assert!(
result.is_err(),
"conversion should fail when corresponding .autorun file is missing"
);
}
#[test]
fn test_convert_autorun_for_rom_converts_v2_file_to_v3() {
let temp_dir = TempDir::new().expect("create temp dir");
let rom_path = temp_dir.path().join("game.nes");
let autorun_path = rom_path.with_extension("autorun");
std::fs::write(
&autorun_path,
serde_json::to_vec_pretty(&serde_json::json!({
"version": 2,
"frames": [
{"player1": 0, "player2": 0},
{"player1": 0, "player2": 0},
{"player1": 1, "player2": 0}
],
"checkpoints": []
}))
.expect("serialize v2 file"),
)
.expect("write v2 autorun file");
convert_autorun_for_rom(
rom_path.to_str().expect("rom path to str"),
AutorunFormat::Json,
)
.expect("convert v2 to v3");
let converted: serde_json::Value =
serde_json::from_slice(&std::fs::read(&autorun_path).expect("read converted file"))
.expect("parse converted file");
assert_eq!(converted["version"], AUTORUN_VERSION);
assert_eq!(converted["frames"].as_array().map(Vec::len), Some(2));
assert_eq!(
converted["frames"][0],
serde_json::json!({"player1": 0, "player2": 0, "repeat": 2})
);
}
#[test]
fn test_recalculate_autorun_for_rom_fails_when_autorun_file_missing() {
let temp_dir = TempDir::new().expect("create temp dir");
let rom_path = temp_dir.path().join("missing.nes");
let result = recalculate_autorun_for_rom(
rom_path.to_str().expect("rom path to str"),
AutorunFormat::default(),
);
assert!(
result.is_err(),
"recalculation should fail when corresponding .autorun file is missing"
);
}
}