#![deny(warnings)] mod cheats; mod device; mod netplay; mod retroachievements; mod savestates; mod ui; use clap::Parser; #[cfg(target_os = "android")] use slint::ComponentHandle; use std::io::Error; #[cfg(target_os = "android")] use ui::android; /// N64 emulator #[derive(Parser, Debug)] #[command(author, version=env!("GIT_DESCRIBE"), about, long_about = None, arg_required_else_help = if cfg!(feature = "gui") { false } else { true })] pub struct Args { pub game: Option, #[arg(short, long)] pub fullscreen: bool, #[arg(long)] pub widescreen: bool, #[arg(long)] pub overclock: Option, #[arg(long)] pub disable_expansion_pak: Option, #[arg(long, value_name = "CHEATS_FILE", hide = true)] pub cheats: Option, #[arg(long, value_name = "NETPLAY_SERVER_ADDR", hide = true)] pub netplay_server_addr: Option, #[arg(long, value_name = "NETPLAY_PLAYER_NUMBER", hide = true)] pub netplay_player_number: Option, #[arg(long, value_name = "NETPLAY_NUMBER_OF_PLAYERS", hide = true)] pub netplay_number_of_players: Option, #[arg(long, value_name = "NETPLAY_INPUT_DELAY", hide = true)] pub netplay_input_delay: Option, #[arg( long, value_name = "PROFILE_NAME", help = "Create a new input profile (keyboard/gamepad mappings)" )] pub configure_input_profile: Option, #[arg(long, help = "Use DirectInput when configuring a new input profile")] pub use_dinput: bool, #[arg( long, value_name = "DEADZONE_PERCENTAGE", help = "Used along with --configure-input-profile to set the deadzone for analog sticks" )] pub deadzone: Option, #[arg( long, value_name = "PROFILE_NAME", help = "Must also specify --port. Used to bind a previously created profile to a port" )] pub bind_input_profile: Option, #[arg( long, help = "Lists connected controllers which can be used in --assign-controller" )] pub list_controllers: bool, #[arg( long, value_name = "CONTROLLER_NUMBER", help = "Must also specify --port. Used to assign a controller listed in --list-controllers to a port" )] pub assign_controller: Option, #[arg( long, value_name = "PORT", help = "Valid values: 1-4. To be used alongside --bind-input-profile and --assign-controller" )] pub port: Option, #[arg( long, help = "Clear all input profile bindings and controller assignments" )] pub clear_input_bindings: bool, #[arg( long, value_name = "SLOT", help = "Load savestate from slot 0-9 when starting the game" )] pub load_state: Option, #[arg( long = "ra-username", value_name = "USERNAME", help = "Username for RetroAchievements" )] pub ra_username: Option, #[arg( long = "ra-password", value_name = "PASSWORD", help = "Password for RetroAchievements" )] pub ra_password: Option, } #[cfg(feature = "gui")] fn set_app_id() { if let Err(e) = slint::set_xdg_app_id(ui::APP_ID) { eprintln!("Could not set application ID: {}", e); } } pub fn create_runtime() -> (tokio::sync::oneshot::Sender<()>, tokio::runtime::Handle) { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let (close_tx, close_rx) = tokio::sync::oneshot::channel::<()>(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().expect("Failed to build runtime"); tx.send(rt.handle().clone()).unwrap(); rt.block_on(close_rx).unwrap(); }); (close_tx, rx.blocking_recv().unwrap()) } pub fn run(args: Args, arg_count: usize) -> std::io::Result<()> { let dirs = ui::get_dirs(); std::fs::create_dir_all(&dirs.config_dir)?; std::fs::create_dir_all(&dirs.cache_dir)?; std::fs::create_dir_all(dirs.data_dir.join("saves"))?; std::fs::create_dir_all(dirs.data_dir.join("states"))?; ui::sdl_hints(); if let Some(game) = args.game { let file_path = std::path::Path::new(&game).to_path_buf(); let Some(rom_contents) = device::get_rom_contents(&file_path) else { return Err(Error::other(format!( "Could not read ROM file: {}", file_path.display() ))); }; let cheats = if let Some(cheats_file) = args.cheats { if let Ok(data) = std::fs::read(cheats_file) && let Ok(cheats) = serde_json::from_slice(&data) { cheats } else { return Err(Error::other("Could not read cheats file")); } } else { let game_crc = ui::storage::get_game_crc(&rom_contents); ui::config::Cheats::new() .cheats .get(&game_crc) .cloned() .unwrap_or_default() }; if let Some(slot) = args.load_state && slot > 9 { return Err(Error::other("Savestate slot must be between 0 and 9")); } let mut device = device::Device::new(true); device.ui.config.ui.recent_roms.retain(|x| *x != game); device.ui.config.ui.recent_roms.insert(0, game); device.ui.config.ui.recent_roms.truncate(5); if args.fullscreen { device.ui.video.fullscreen = true; } else { device.ui.video.fullscreen = device.ui.config.video.fullscreen; } if args.widescreen { device.ui.video.widescreen = true; } else { device.ui.video.widescreen = device.ui.config.video.widescreen; } let overclock = args .overclock .unwrap_or(device.ui.config.emulation.overclock); let disable_expansion_pak = args .disable_expansion_pak .unwrap_or(device.ui.config.emulation.disable_expansion_pak); let mut shutdown_tx = None; let mut usb_handle = None; if args.netplay_server_addr.is_none() { for i in 0..4 { if device.ui.config.input.transfer_pak[i] && !device.ui.config.input.gb_rom_path[i].is_empty() && !device.ui.config.input.gb_ram_path[i].is_empty() && let Ok(rom) = std::fs::read(&device.ui.config.input.gb_rom_path[i]) && let Ok(ram) = std::fs::read(&device.ui.config.input.gb_ram_path[i]) { device::controller::gbcart::init(&mut device.transferpaks[i].cart, &rom, &ram); } } if device.ui.config.emulation.usb { (shutdown_tx, usb_handle, device.ui.usb) = ui::usb::init(); } } let file_path = dirs.config_dir.join("retroachievements.json"); let ra_config = if let ra_config = std::fs::read(&file_path).unwrap_or_default() && let ra_config = serde_json::from_slice::(ra_config.as_ref()) .unwrap_or_default() && (ra_config.enabled || args.ra_username.is_some()) { let username = args.ra_username.unwrap_or(ra_config.username.clone()); retroachievements::init_client( if cfg!(ra_hardcore_enabled) && args.netplay_server_addr.is_none() { ra_config.hardcore } else { false }, ra_config.challenge, ra_config.leaderboard, ); let (tx, rx) = tokio::sync::oneshot::channel::(); if let Some(password) = args.ra_password { retroachievements::login_user(username, password, tx); } else if !ra_config.token.is_empty() { retroachievements::login_token_user(username, ra_config.token.clone(), tx); } else { tx.send(false).unwrap(); } rx.blocking_recv().unwrap(); ra_config } else { retroachievements::RAConfig::default() }; let netplay_config = if let Some(server_addr) = args.netplay_server_addr && let Some(player_number) = args.netplay_player_number && let Some(number_of_players) = args.netplay_number_of_players && let Some(input_delay) = args.netplay_input_delay { Some(netplay::NetplayConfig { server_addr, player_number, number_of_players, input_delay, ice_config_path: dirs.cache_dir.join("ice_config.json"), }) } else { None }; device::run_game( &mut device, &rom_contents, ui::GameSettings { overclock, disable_expansion_pak, cheats, load_savestate_slot: args.load_state, }, ra_config, netplay_config, ); // on Android, the client is shut down in the app_window function #[cfg(not(target_os = "android"))] retroachievements::shutdown_client(); if device.netplay.is_none() { for i in 0..4 { if device.ui.config.input.transfer_pak[i] && !device.ui.config.input.gb_ram_path[i].is_empty() && !device.transferpaks[i].cart.ram.is_empty() { device::controller::gbcart::save( &mut device.transferpaks[i].cart, device.vi.elapsed_time as i64, &device.ui.config.input.gb_ram_path[i], ); } } } ui::usb::close(shutdown_tx, usb_handle); } else if arg_count > 1 { let mut config = ui::config::Config::new(); if let Some(profile) = args.configure_input_profile { ui::input_profile::configure_input_profile( &mut config, profile, args.use_dinput, args.deadzone.unwrap_or(ui::input_profile::DEADZONE_DEFAULT), ); ui::sdl_close(); return Ok(()); } else { if args.clear_input_bindings { ui::input::clear_bindings(&mut config); return Ok(()); } if let Some(port) = args.port && !(1..=4).contains(&port) { return Err(Error::other("Port must be between 1 and 4")); } ui::sdl_init(sdl3_sys::init::SDL_INIT_GAMEPAD); if args.list_controllers { let controllers = ui::input::get_controller_names(); for (i, controller) in controllers.iter().enumerate() { println!("Controller {i}: {controller}"); } } else { if let Some(assign_controller) = args.assign_controller { let Some(port) = args.port else { ui::sdl_close(); return Err(Error::other("Must specify port number")); }; ui::input::assign_controller(&mut config, assign_controller - 1, port); } if let Some(profile) = args.bind_input_profile { let Some(port) = args.port else { ui::sdl_close(); return Err(Error::other("Must specify port number")); }; ui::input::bind_input_profile(&mut config, profile, port); } } } } else { #[cfg(feature = "gui")] { let app = ui::gui::AppWindow::new().unwrap(); let no_intro_map = std::sync::Arc::new(tokio::sync::Mutex::new(rustc_hash::FxHashMap::default())); set_app_id(); ui::gui::app_window(&app, false, no_intro_map); } } ui::sdl_close(); Ok(()) } #[cfg(target_os = "android")] #[unsafe(no_mangle)] fn android_main(app: slint::android::AndroidApp) { let (close_tx, handle) = create_runtime(); let _guard = handle.enter(); slint::android::init_with_event_listener(app.clone(), move |event| match event { slint::android::android_activity::PollEvent::Main(main_event) => match main_event { slint::android::android_activity::MainEvent::TerminateWindow { .. } => { if let Ok(weak_app_window) = android::WEAK_SLINT_WINDOW.lock() && let Some(weak_app_window) = weak_app_window.as_ref() { weak_app_window .upgrade_in_event_loop(move |handle| { ui::gui::save_settings(&handle); handle.invoke_netplay_close(); }) .unwrap(); } } _ => {} }, _ => {} }) .unwrap(); let app_window = ui::gui::AppWindow::new().unwrap(); *android::WEAK_SLINT_WINDOW.lock().unwrap() = Some(app_window.as_weak()); *android::ANDROID_APP.lock().unwrap() = Some(app); let dirs = ui::get_dirs(); std::fs::create_dir_all(&dirs.config_dir).unwrap(); std::fs::create_dir_all(&dirs.cache_dir).unwrap(); std::fs::create_dir_all(dirs.data_dir.join("saves")).unwrap(); std::fs::create_dir_all(dirs.data_dir.join("states")).unwrap(); let no_intro_map = std::sync::Arc::new(tokio::sync::Mutex::new(rustc_hash::FxHashMap::default())); ui::gui::app_window(&app_window, true, no_intro_map); close_tx.send(()).unwrap(); *android::WEAK_SLINT_WINDOW.lock().unwrap() = None; *android::ANDROID_APP.lock().unwrap() = None; }