Run rom android (#956)

* run rom android

* more

* more

* more

* more

* more

* more

* more

* more
This commit is contained in:
Logan McNaughton
2026-05-26 20:09:57 +02:00
committed by GitHub
parent 9c9f1a3000
commit 32f54f157b
8 changed files with 287 additions and 127 deletions
+1
View File
@@ -27,6 +27,7 @@ android {
buildTypes {
release {
signingConfig = signingConfigs["debug"]
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -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<String> = 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()
}
+1
View File
@@ -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);
+81 -3
View File
@@ -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<String>,
#[arg(short, long)]
pub fullscreen: bool,
#[arg(long)]
pub overclock: Option<bool>,
#[arg(long)]
pub disable_expansion_pak: Option<bool>,
#[arg(long, value_name = "CHEATS_FILE", hide = true)]
pub cheats: Option<String>,
#[arg(long, value_name = "NETPLAY_PEER_ADDR", hide = true)]
pub netplay_peer_addr: Option<String>,
#[arg(long, value_name = "NETPLAY_PLAYER_NUMBER", hide = true)]
pub netplay_player_number: Option<u8>,
#[arg(
long,
value_name = "PROFILE_NAME",
help = "Create a new input profile (keyboard/gamepad mappings)"
)]
pub configure_input_profile: Option<String>,
#[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<i32>,
#[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<String>,
#[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<i32>,
#[arg(
long,
value_name = "PORT",
help = "Valid values: 1-4. To be used alongside --bind-input-profile and --assign-controller"
)]
pub port: Option<usize>,
#[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<u32>,
#[arg(
long = "ra-username",
value_name = "USERNAME",
help = "Username for RetroAchievements"
)]
pub ra_username: Option<String>,
#[arg(
long = "ra-password",
value_name = "PASSWORD",
help = "Password for RetroAchievements"
)]
pub ra_password: Option<String>,
}
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 {
+4 -1
View File
@@ -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
}
-80
View File
@@ -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<reqwest::Client> = std::sync::LazyLock::new(|| {
reqwest::Client::builder()
@@ -29,85 +28,6 @@ pub static WEB_CLIENT: std::sync::LazyLock<reqwest::Client> = 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<String>,
#[arg(short, long)]
pub fullscreen: bool,
#[arg(long)]
pub overclock: Option<bool>,
#[arg(long)]
pub disable_expansion_pak: Option<bool>,
#[arg(long, value_name = "CHEATS_FILE", hide = true)]
pub cheats: Option<String>,
#[arg(long, value_name = "NETPLAY_PEER_ADDR", hide = true)]
pub netplay_peer_addr: Option<String>,
#[arg(long, value_name = "NETPLAY_PLAYER_NUMBER", hide = true)]
pub netplay_player_number: Option<u8>,
#[arg(
long,
value_name = "PROFILE_NAME",
help = "Create a new input profile (keyboard/gamepad mappings)"
)]
pub configure_input_profile: Option<String>,
#[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<i32>,
#[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<String>,
#[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<i32>,
#[arg(
long,
value_name = "PORT",
help = "Valid values: 1-4. To be used alongside --bind-input-profile and --assign-controller"
)]
pub port: Option<usize>,
#[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<u32>,
#[arg(
long = "ra-username",
value_name = "USERNAME",
help = "Username for RetroAchievements"
)]
pub ra_username: Option<String>,
#[arg(
long = "ra-password",
value_name = "PASSWORD",
help = "Password for RetroAchievements"
)]
pub ra_password: Option<String>,
}
#[derive(Clone)]
pub struct Dirs {
pub config_dir: std::path::PathBuf,
+134 -22
View File
@@ -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<Option<slint::android::AndroidApp>> =
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<ui::gui::NetplayDevice>,
weak: slint::Weak<ui::gui::AppWindow>,
) {
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<ui::gui::NetplayDevice>,
weak: slint::Weak<ui::gui::AppWindow>,
) -> 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::<Global<AndroidActivity>>(&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<JavaVM> {
}
}
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<String> {
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<ControllerInfo> {
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::<AndroidIntent>(&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();
}
}
_ => {}
}
}
+34 -21
View File
@@ -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<NetplayDevice>,
#[cfg(target_os = "android")] _weak: slint::Weak<AppWindow>,
#[cfg(not(target_os = "android"))] weak: slint::Weak<AppWindow>,
weak: slint::Weak<AppWindow>,
) {
#[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<std::path::PathBuf> {
#[cfg(target_os = "android")]
return ui::android::select_rom(rom_dir).await;