From 32f54f157b66e8b2158ca02b1d080461e4952b7b Mon Sep 17 00:00:00 2001 From: Logan McNaughton <848146+loganmc10@users.noreply.github.com> Date: Tue, 26 May 2026 20:09:57 +0200 Subject: [PATCH] Run rom android (#956) * run rom android * more * more * more * more * more * more * more * more --- android-project/app/build.gradle.kts | 1 + .../github/gopher64/gopher64/N64Activity.kt | 32 ++++ parallel-rdp/interface.cpp | 1 + src/lib.rs | 84 +++++++++- src/main.rs | 5 +- src/ui.rs | 80 --------- src/ui/android.rs | 156 +++++++++++++++--- src/ui/gui.rs | 55 +++--- 8 files changed, 287 insertions(+), 127 deletions(-) diff --git a/android-project/app/build.gradle.kts b/android-project/app/build.gradle.kts index dedd75e5..a01f7fd6 100644 --- a/android-project/app/build.gradle.kts +++ b/android-project/app/build.gradle.kts @@ -27,6 +27,7 @@ android { buildTypes { release { + signingConfig = signingConfigs["debug"] isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), diff --git a/android-project/app/src/main/java/io/github/gopher64/gopher64/N64Activity.kt b/android-project/app/src/main/java/io/github/gopher64/gopher64/N64Activity.kt index 2fabc26f..f0231ebb 100644 --- a/android-project/app/src/main/java/io/github/gopher64/gopher64/N64Activity.kt +++ b/android-project/app/src/main/java/io/github/gopher64/gopher64/N64Activity.kt @@ -1,10 +1,12 @@ package io.github.gopher64.gopher64 +import android.content.Intent import org.libsdl.app.SDLActivity class N64Activity : SDLActivity() { companion object { const val CONFIGURE_INPUT_PROFILE = 2 + const val RUN_ROM = 3 } override fun getLibraries(): Array = arrayOf( @@ -35,6 +37,36 @@ class N64Activity : SDLActivity() { } setResult(RESULT_OK) // so that the profiles are updated in the GUI return args.toTypedArray() + } else if (request_code == RUN_ROM) { + val file_path = intent.getStringExtra("file_path") ?: return super.getArguments() + val overclock = intent.getBooleanExtra("overclock", false) + val disable_expansion_pak = intent.getBooleanExtra("disable_expansion_pak", false) + val args = mutableListOf( + file_path, + "--fullscreen", + "--overclock", + overclock.toString(), + "--disable-expansion-pak", + disable_expansion_pak.toString()) + + val dataIntent = Intent() + + val netplay_peer_addr = intent.getStringExtra("netplay_peer_addr") + val cheats = intent.getStringExtra("cheats") + if (netplay_peer_addr != null && cheats != null) { + args.add("--netplay-peer-addr") + args.add(netplay_peer_addr) + args.add("--netplay-player-number") + args.add(intent.getIntExtra("netplay_player_number", 4).toString()) + args.add("--cheats") + args.add(cheats) + dataIntent.putExtra("cheats_path", cheats) + } + + dataIntent.putExtra("file_path", file_path) + + setResult(RESULT_OK, dataIntent) + return args.toTypedArray() } else { return super.getArguments() } diff --git a/parallel-rdp/interface.cpp b/parallel-rdp/interface.cpp index 417ae57e..d9626660 100644 --- a/parallel-rdp/interface.cpp +++ b/parallel-rdp/interface.cpp @@ -168,6 +168,7 @@ bool sdl_event_filter(void *userdata, SDL_Event *event) { callback.paused = !callback.paused; } break; + case SDL_SCANCODE_AC_BACK: case SDL_SCANCODE_ESCAPE: if (gfx_info.fullscreen) { SDL_zero(user_event); diff --git a/src/lib.rs b/src/lib.rs index eb6e1049..73123907 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,86 @@ use std::io::Error; #[cfg(target_os = "android")] use ui::android; -pub async fn run() -> std::io::Result<()> { +/// 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 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_PEER_ADDR", hide = true)] + pub netplay_peer_addr: Option, + #[arg(long, value_name = "NETPLAY_PLAYER_NUMBER", hide = true)] + pub netplay_player_number: 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, +} + +pub async fn run(args: Args, arg_count: usize) -> std::io::Result<()> { let dirs = ui::get_dirs(); std::fs::create_dir_all(&dirs.config_dir)?; @@ -24,7 +103,6 @@ pub async fn run() -> std::io::Result<()> { ui::sdl_hints(); - let args = ui::Args::parse(); 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 { @@ -167,7 +245,7 @@ pub async fn run() -> std::io::Result<()> { if let Some(shutdown_tx) = &shutdown_tx { ui::usb::close(shutdown_tx, usb_handle).await; } - } else if std::env::args().count() > 1 { + } else if arg_count > 1 { let mut config = ui::config::Config::new(); if let Some(profile) = args.configure_input_profile { diff --git a/src/main.rs b/src/main.rs index e275fc0a..978f7cea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,10 @@ #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; +use clap::Parser; + #[tokio::main(worker_threads = 4)] async fn main() -> std::io::Result<()> { - gopher64::run().await + let args = gopher64::Args::parse(); + gopher64::run(args, std::env::args().count()).await } diff --git a/src/ui.rs b/src/ui.rs index 2bf4d42c..87d6488c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,7 +16,6 @@ pub mod usb; pub mod video; #[cfg(all(feature = "gui", not(target_os = "android")))] pub mod vru; -use clap::Parser; pub static WEB_CLIENT: std::sync::LazyLock = std::sync::LazyLock::new(|| { reqwest::Client::builder() @@ -29,85 +28,6 @@ pub static WEB_CLIENT: std::sync::LazyLock = std::sync::LazyLoc .unwrap() }); -/// 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 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_PEER_ADDR", hide = true)] - pub netplay_peer_addr: Option, - #[arg(long, value_name = "NETPLAY_PLAYER_NUMBER", hide = true)] - pub netplay_player_number: 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, -} - #[derive(Clone)] pub struct Dirs { pub config_dir: std::path::PathBuf, diff --git a/src/ui/android.rs b/src/ui/android.rs index 5db00f69..943432ab 100644 --- a/src/ui/android.rs +++ b/src/ui/android.rs @@ -1,3 +1,5 @@ +use crate::Args; +use crate::run; use crate::ui; use clap::Parser; use jni::objects::{JClass, JObject, JString}; @@ -8,6 +10,7 @@ use std::os::fd::FromRawFd; const REQUEST_SELECT_ROM: jint = 1; const CONFIGURE_INPUT_PROFILE: jint = 2; +const RUN_ROM: jint = 3; pub static ANDROID_APP: std::sync::Mutex> = std::sync::Mutex::new(None); @@ -82,6 +85,7 @@ bind_java_type! { sig = (extra: JString, value: jint) -> AndroidIntent, name = "putExtra", }, + fn get_string_extra(name: JString) -> JString, fn set_class_name(package_name: JString, class_name: JString) -> AndroidIntent, }, } @@ -111,6 +115,7 @@ bind_java_type! { methods { static fn parse(uri_string: JString) -> AndroidUri, fn to_string() -> JString, + static fn decode(path: JString) -> JString, }, } @@ -163,20 +168,11 @@ pub async extern "C" fn gopher64_sdl_main( argc: std::ffi::c_int, argv: *mut *mut std::ffi::c_char, ) -> std::ffi::c_int { - ui::sdl_hints(); - let raw = argv_to_strings(argc, argv); - let args = ui::Args::try_parse_from(raw).unwrap(); - if let Some(profile) = args.configure_input_profile { - let mut config = ui::config::Config::new(); - ui::input::configure_input_profile( - &mut config, - profile, - args.use_dinput, - args.deadzone.unwrap_or(ui::input::DEADZONE_DEFAULT), - ); - - ui::sdl_close(); + let args = Args::try_parse_from(raw).unwrap(); + if let Err(err) = run(args, argc as usize).await { + eprintln!("Error running game: {err:?}"); + return 1; } 0 } @@ -188,14 +184,14 @@ pub fn spawn_configure_input_profile( ) { if let Some(vm) = get_vm() { if let Err(err) = vm.attach_current_thread(|env| { - start_n64_activity_on_jvm(env, profile_name.to_string(), dinput, deadzone) + start_configure_input_profile_on_jvm(env, profile_name.to_string(), dinput, deadzone) }) { eprintln!("JNI error while starting N64Activity: {err:?}"); } } } -fn start_n64_activity_on_jvm( +fn start_configure_input_profile_on_jvm( env: &mut Env<'_>, profile_name: String, dinput: bool, @@ -232,15 +228,82 @@ fn start_n64_activity_on_jvm( } pub fn run_rom( - _file_path: std::path::PathBuf, - _game_settings: ui::GameSettings, + file_path: std::path::PathBuf, + game_settings: ui::GameSettings, netplay: Option, + weak: slint::Weak, ) { - if let Some(netplay) = netplay { - println!( - "Netplay peer addr: {} player number: {}", - netplay.peer_addr, netplay.player_number - ); + if let Some(vm) = get_vm() { + if let Err(err) = vm.attach_current_thread(|env| { + start_run_rom_on_jvm(env, file_path, game_settings, netplay, weak) + }) { + eprintln!("JNI error while starting N64Activity: {err:?}"); + } + } +} + +fn start_run_rom_on_jvm( + env: &mut Env<'_>, + file_path: std::path::PathBuf, + game_settings: ui::GameSettings, + netplay: Option, + weak: slint::Weak, +) -> jni::errors::Result<()> { + if let Ok(app) = ANDROID_APP.lock() + && let Some(app) = app.as_ref() + { + let raw_activity_global = app.activity_as_ptr() as jni::sys::jobject; + let activity = unsafe { env.as_cast_raw::>(&raw_activity_global)? }; + + let package_name = JString::from_str(env, "io.github.gopher64.gopher64")?; + let class_name = JString::from_str(env, "io.github.gopher64.gopher64.N64Activity")?; + + let file_path_key = JString::from_str(env, "file_path")?; + let file_path_value = JString::from_str(env, file_path.to_str().unwrap())?; + let overclock_key = JString::from_str(env, "overclock")?; + let disable_expansion_pak_key = JString::from_str(env, "disable_expansion_pak")?; + let request_code_key = JString::from_str(env, "request_code")?; + let mut intent = AndroidIntent::new(env)? + .set_class_name(env, &package_name, &class_name)? + .put_extra_int(env, &request_code_key, RUN_ROM)? + .put_extra_string(env, &file_path_key, &file_path_value)? + .put_extra_boolean(env, &overclock_key, game_settings.overclock)? + .put_extra_boolean( + env, + &disable_expansion_pak_key, + game_settings.disable_expansion_pak, + )?; + + if let Some(netplay) = netplay { + let netplay_peer_addr_key = JString::from_str(env, "netplay_peer_addr")?; + let netplay_player_number_key = JString::from_str(env, "netplay_player_number")?; + let netplay_peer_addr_value = JString::from_str(env, &netplay.peer_addr.to_string())?; + let cheats_key = JString::from_str(env, "cheats")?; + let cheats_path = ui::get_dirs().cache_dir.join("cheats.json"); + let cheats_value = JString::from_str(env, cheats_path.to_str().unwrap())?; + + let f = std::fs::File::create(cheats_path).unwrap(); + serde_json::to_writer_pretty(f, &game_settings.cheats).unwrap(); + + intent = intent + .put_extra_string(env, &netplay_peer_addr_key, &netplay_peer_addr_value)? + .put_extra_int( + env, + &netplay_player_number_key, + netplay.player_number as jint, + )? + .put_extra_string(env, &cheats_key, &cheats_value)?; + } + + weak.upgrade_in_event_loop(move |handle| handle.set_game_running(true)) + .unwrap(); + + activity + .as_ref() + .start_activity_for_result(env, &intent, RUN_ROM)?; + Ok(()) + } else { + Err(jni::errors::Error::UninitializedJavaVM) } } @@ -254,6 +317,27 @@ fn get_vm() -> Option { } } +pub fn decode_path(path: &str) -> String { + if let Some(vm) = get_vm() { + match vm.attach_current_thread(|env| decode_path_on_jvm(env, path)) { + Ok(decoded_path) => decoded_path, + Err(err) => { + eprintln!("JNI error while decoding path: {err:?}"); + String::new() + } + } + } else { + eprintln!("Android app not initialized"); + String::new() + } +} + +fn decode_path_on_jvm(env: &mut Env<'_>, path: &str) -> jni::errors::Result { + let path = JString::from_str(env, path)?; + let decoded_path = AndroidUri::decode(env, &path)?; + Ok(decoded_path.try_to_string(env)?) +} + /// Lists connected gamepads and joysticks using the Android framework. pub fn list_controllers() -> Vec { if let Some(vm) = get_vm() { @@ -536,6 +620,34 @@ pub extern "system" fn Java_io_github_gopher64_gopher64_SlintActivity_nativeOnAc ui::gui::update_input_profiles(&weak_app_window, &config); } } + RUN_ROM => { + let result_intent = unsafe { env.as_cast_raw::(&intent_data)? }; + + let file_path_key = JString::from_str(env, "file_path")?; + let file_path = result_intent + .as_ref() + .get_string_extra(env, &file_path_key)? + .try_to_string(env)?; + + let cheats_path_key = JString::from_str(env, "cheats_path")?; + if let Ok(cheats_path) = result_intent + .as_ref() + .get_string_extra(env, &cheats_path_key) + && let Ok(cheats_path) = cheats_path.try_to_string(env) + { + let _ = std::fs::remove_file(cheats_path); + } + if let Ok(weak_app_window) = 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::update_recent_roms(&handle, file_path.into()); + handle.set_game_running(false) + }) + .unwrap(); + } + } _ => {} } } diff --git a/src/ui/gui.rs b/src/ui/gui.rs index 0be86406..0f702eed 100644 --- a/src/ui/gui.rs +++ b/src/ui/gui.rs @@ -111,7 +111,7 @@ fn local_game_window(app: &AppWindow, config: &ui::config::Config) { .map(|x| { ( x.into(), - std::path::Path::new(x) + std::path::Path::new(&decode_path(x)) .file_name() .unwrap() .to_str() @@ -541,11 +541,10 @@ pub fn run_rom( file_path: std::path::PathBuf, game_settings: ui::GameSettings, netplay: Option, - #[cfg(target_os = "android")] _weak: slint::Weak, - #[cfg(not(target_os = "android"))] weak: slint::Weak, + weak: slint::Weak, ) { #[cfg(target_os = "android")] - ui::android::run_rom(file_path, game_settings, netplay); + ui::android::run_rom(file_path, game_settings, netplay, weak); #[cfg(not(target_os = "android"))] tokio::spawn(async move { @@ -571,7 +570,7 @@ pub fn run_rom( ]); let cheats_path = ui::get_dirs().cache_dir.join("cheats.json"); if let Some(netplay_device) = netplay { - let f = std::fs::File::create(cheats_path.to_str().unwrap()).unwrap(); + let f = std::fs::File::create(&cheats_path).unwrap(); serde_json::to_writer_pretty(f, &game_settings.cheats).unwrap(); command.args([ @@ -595,28 +594,14 @@ pub fn run_rom( eprintln!("Failed to run game"); } - let _ = std::fs::remove_file(cheats_path.to_str().unwrap()); + let _ = std::fs::remove_file(cheats_path); weak.upgrade_in_event_loop(move |handle| { if let Some(rom_dir) = file_path.parent().unwrap().to_str() { handle.set_rom_dir(rom_dir.into()); } if success { - let recent_roms = slint::VecModel::default(); - recent_roms.push(( - file_path.to_str().unwrap().into(), - file_path.file_name().unwrap().to_str().unwrap().into(), - )); - - for rom in handle.get_recent_roms().iter() { - if rom.0 != file_path.to_str().unwrap() - && recent_roms.row_count() < 5 - && rom_exists(&rom.0) - { - recent_roms.push(rom); - } - } - handle.set_recent_roms(slint::ModelRc::from(std::rc::Rc::new(recent_roms))); + update_recent_roms(&handle, file_path); } handle.set_game_running(false); }) @@ -624,6 +609,34 @@ pub fn run_rom( }); } +fn decode_path(path: &str) -> String { + #[cfg(target_os = "android")] + return ui::android::decode_path(path); + #[cfg(not(target_os = "android"))] + return path.to_string(); +} + +pub fn update_recent_roms(app: &AppWindow, file_path: std::path::PathBuf) { + let recent_roms = slint::VecModel::default(); + recent_roms.push(( + file_path.to_str().unwrap().into(), + std::path::Path::new(&decode_path(file_path.to_str().unwrap())) + .file_name() + .unwrap() + .to_str() + .unwrap() + .into(), + )); + + for rom in app.get_recent_roms().iter() { + if rom.0 != file_path.to_str().unwrap() && recent_roms.row_count() < 5 && rom_exists(&rom.0) + { + recent_roms.push(rom); + } + } + app.set_recent_roms(slint::ModelRc::from(std::rc::Rc::new(recent_roms))); +} + pub async fn select_rom(rom_dir: slint::SharedString) -> Option { #[cfg(target_os = "android")] return ui::android::select_rom(rom_dir).await;