From e932239a639c2ac21aafa09d6266bd5ef59c902c Mon Sep 17 00:00:00 2001 From: Logan McNaughton <848146+loganmc10@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:59:03 -0600 Subject: [PATCH] RetroAchievements (#710) * retroachievements * add rc_api_info * disable hardcore * clear password after login attempt * proper check for hardcore * more set_hardcore * change checkbox * add more stubs * add more UI * add button for RA website * cleanup http callback --- .gitmodules | 3 + build.rs | 66 +++++ retroachievements/rcheevos | 1 + retroachievements/retroachievements.c | 284 ++++++++++++++++++++++ retroachievements/retroachievements.h | 23 ++ src/device.rs | 17 +- src/device/cart/rom.rs | 4 +- src/device/rdram.rs | 3 + src/device/vi.rs | 6 +- src/main.rs | 39 +++ src/retroachievements.rs | 304 ++++++++++++++++++++++++ src/savestates.rs | 9 +- src/ui/gui.rs | 15 ++ src/ui/gui/appwindow.slint | 28 ++- src/ui/gui/pages.slint | 1 + src/ui/gui/retroachievements_page.slint | 105 ++++++++ src/ui/netplay.rs | 6 +- src/ui/video.rs | 7 +- 18 files changed, 902 insertions(+), 19 deletions(-) create mode 160000 retroachievements/rcheevos create mode 100644 retroachievements/retroachievements.c create mode 100644 retroachievements/retroachievements.h create mode 100644 src/retroachievements.rs create mode 100644 src/ui/gui/retroachievements_page.slint diff --git a/.gitmodules b/.gitmodules index 1abe78ca..69845f27 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,3 +6,6 @@ path = src/compat/sse2neon url = https://github.com/DLTcollab/sse2neon.git branch = master +[submodule "retroachievements/rcheevos"] + path = retroachievements/rcheevos + url = https://github.com/RetroAchievements/rcheevos.git diff --git a/build.rs b/build.rs index 1b108c0a..0fb28314 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,6 @@ fn main() { println!("cargo::rerun-if-changed=parallel-rdp"); + println!("cargo::rerun-if-changed=retroachievements"); println!("cargo::rerun-if-changed=src/compat"); let slint_config = slint_build::CompilerConfiguration::new().with_style("cosmic".into()); @@ -69,6 +70,41 @@ fn main() { .join("include"), ); + let mut retroachievements_build = cc::Build::new(); + retroachievements_build + .flag("-Wno-unused-parameter") + .include("retroachievements/rcheevos/include") + .flag("-DRC_CLIENT_SUPPORTS_HASH") + .file("retroachievements/rcheevos/src/rc_client.c") + .file("retroachievements/rcheevos/src/rc_compat.c") + .file("retroachievements/rcheevos/src/rc_util.c") + .file("retroachievements/rcheevos/src/rcheevos/alloc.c") + .file("retroachievements/rcheevos/src/rcheevos/condition.c") + .file("retroachievements/rcheevos/src/rcheevos/condset.c") + .file("retroachievements/rcheevos/src/rcheevos/consoleinfo.c") + .file("retroachievements/rcheevos/src/rcheevos/format.c") + .file("retroachievements/rcheevos/src/rcheevos/lboard.c") + .file("retroachievements/rcheevos/src/rcheevos/memref.c") + .file("retroachievements/rcheevos/src/rcheevos/operand.c") + .file("retroachievements/rcheevos/src/rcheevos/richpresence.c") + .file("retroachievements/rcheevos/src/rcheevos/runtime.c") + .file("retroachievements/rcheevos/src/rcheevos/runtime_progress.c") + .file("retroachievements/rcheevos/src/rcheevos/trigger.c") + .file("retroachievements/rcheevos/src/rcheevos/value.c") + .file("retroachievements/rcheevos/src/rapi/rc_api_common.c") + .file("retroachievements/rcheevos/src/rapi/rc_api_info.c") + .file("retroachievements/rcheevos/src/rapi/rc_api_runtime.c") + .file("retroachievements/rcheevos/src/rapi/rc_api_user.c") + .file("retroachievements/rcheevos/src/rhash/aes.c") + .file("retroachievements/rcheevos/src/rhash/cdreader.c") + .file("retroachievements/rcheevos/src/rhash/md5.c") + .file("retroachievements/rcheevos/src/rhash/hash.c") + .file("retroachievements/rcheevos/src/rhash/hash_disc.c") + .file("retroachievements/rcheevos/src/rhash/hash_encrypted.c") + .file("retroachievements/rcheevos/src/rhash/hash_rom.c") + .file("retroachievements/rcheevos/src/rhash/hash_zip.c") + .file("retroachievements/retroachievements.c"); + let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap(); let opt_flag = if arch == "x86_64" { @@ -84,6 +120,7 @@ fn main() { volk_build.flag(opt_flag); rdp_build.flag(opt_flag); simd_build.flag(opt_flag); + retroachievements_build.flag(opt_flag); if os == "windows" { volk_build.flag("-DVK_USE_PLATFORM_WIN32_KHR"); @@ -111,12 +148,41 @@ fn main() { volk_build.flag("-flto=thin"); rdp_build.flag("-flto=thin"); simd_build.flag("-flto=thin"); + retroachievements_build.flag("-flto=thin"); volk_build.compile("volk"); rdp_build.compile("parallel-rdp"); + retroachievements_build.compile("retroachievements"); let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let retroachievements_bindings = bindgen::Builder::default() + .header("retroachievements/retroachievements.h") + .allowlist_function("ra_init_client") + .allowlist_function("ra_shutdown_client") + .allowlist_function("ra_get_hardcore") + .allowlist_function("ra_load_game") + .allowlist_function("ra_set_dmem") + .allowlist_function("ra_do_frame") + .allowlist_function("ra_do_idle") + .allowlist_function("ra_http_callback") + .allowlist_function("ra_logout_user") + .allowlist_function("ra_login_user") + .allowlist_function("ra_login_token_user") + .allowlist_function("ra_is_user_logged_in") + .allowlist_function("ra_get_username") + .allowlist_function("ra_get_token") + .allowlist_function("ra_state_size") + .allowlist_function("ra_save_state") + .allowlist_function("ra_load_state") + .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) + .generate() + .expect("Unable to generate bindings"); + + retroachievements_bindings + .write_to_file(out_path.join("retroachievements_bindings.rs")) + .expect("Couldn't write bindings!"); + let parallel_bindings = bindgen::Builder::default() .header("parallel-rdp/interface.hpp") .allowlist_function("rdp_init") diff --git a/retroachievements/rcheevos b/retroachievements/rcheevos new file mode 160000 index 00000000..e9ca3694 --- /dev/null +++ b/retroachievements/rcheevos @@ -0,0 +1 @@ +Subproject commit e9ca3694c862b61235595176dac4b22677848c93 diff --git a/retroachievements/retroachievements.c b/retroachievements/retroachievements.c new file mode 100644 index 00000000..a272f6e6 --- /dev/null +++ b/retroachievements/retroachievements.c @@ -0,0 +1,284 @@ +#include "retroachievements.h" +#include "../parallel-rdp/interface.hpp" +#include +#include +#include +#include +#include + +void rust_server_call(const char *url, const char *post_data, + rc_client_server_callback_t callback, + void *callback_data); +void store_retroachievements_credentials(const char *username, + const char *token, void *userdata); + +rc_client_t *g_client = NULL; +uint8_t *g_dmem = NULL; +size_t g_dmem_size = 0; +bool g_game_loaded = false; +bool g_user_logged_in = false; +const char *g_username = NULL; +const char *g_token = NULL; + +static uint32_t read_memory(uint32_t address, uint8_t *buffer, + uint32_t num_bytes, rc_client_t *client) { + if (address + num_bytes >= g_dmem_size) + return 0; + memcpy(buffer, &g_dmem[address], num_bytes); + return num_bytes; +} + +static void server_call(const rc_api_request_t *request, + rc_client_server_callback_t callback, + void *callback_data, rc_client_t *client) { + rust_server_call(request->url, request->post_data, callback, callback_data); +} + +void ra_http_callback(const char *content, size_t content_size, int status_code, + void *callback, void *callback_data) { + // Prepare a data object to pass the HTTP response to the callback + rc_api_server_response_t server_response; + memset(&server_response, 0, sizeof(server_response)); + server_response.body = content; + server_response.body_length = content_size; + server_response.http_status_code = status_code; + + // handle non-http errors (socket timeout, no internet available, etc) + if (status_code == 0) { + // Let rc_client know the error was not catastrophic and could be retried. + // It may decide to retry or just immediately pass the error to the + // callback. To prevent possible retries, use + // RC_API_SERVER_RESPONSE_CLIENT_ERROR. + server_response.http_status_code = + RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR; + } + + ((rc_client_server_callback_t)callback)(&server_response, callback_data); +} + +static void log_message(const char *message, const rc_client_t *client) { + printf("RetroAchievements: %s\n", message); +} + +static void login_callback(int result, const char *error_message, + rc_client_t *client, void *userdata) { + // If not successful, just report the error and bail. + if (result != RC_OK) { + printf("RetroAchievements: Login failed: %s\n", error_message); + store_retroachievements_credentials(NULL, NULL, userdata); + return; + } + + // Login was successful. Capture the token for future logins so we don't have + // to store the password anywhere. + const rc_client_user_t *user = rc_client_get_user_info(client); + store_retroachievements_credentials(user->username, user->token, userdata); + + g_username = user->username; + g_token = user->token; + g_user_logged_in = true; +} + +bool ra_is_user_logged_in() { return g_user_logged_in; } + +const char *ra_get_username() { return g_username; } +const char *ra_get_token() { return g_token; } + +void ra_logout_user() { + g_user_logged_in = false; + rc_client_logout(g_client); +} + +void ra_login_user(const char *username, const char *password, void *userdata) { + // This will generate an HTTP payload and call the server_call chain above. + // Eventually, login_callback will be called to let us know if the login was + // successful. + g_user_logged_in = false; + rc_client_begin_login_with_password(g_client, username, password, + login_callback, userdata); +} + +void ra_login_token_user(const char *username, const char *token, + void *userdata) { + // This is exactly the same functionality as + // rc_client_begin_login_with_password, but uses the token captured from the + // first login instead of a password. Note that it uses the same callback. + g_user_logged_in = false; + rc_client_begin_login_with_token(g_client, username, token, login_callback, + userdata); +} + +static void load_game_callback(int result, const char *error_message, + rc_client_t *client, void *userdata) { + if (result != RC_OK) { + printf("RetroAchievements: game load failed: %s\n", error_message); + return; + } + char buffer[512]; + const rc_client_game_t *game = rc_client_get_game_info(client); + rc_client_user_game_summary_t summary; + rc_client_get_user_game_summary(client, &summary); + + snprintf(buffer, sizeof(buffer), "RA Loaded: %s", game->title); + rdp_onscreen_message(buffer); + + int hardcore_enabled = rc_client_get_hardcore_enabled(client); + snprintf(buffer, sizeof(buffer), "RA mode: %s", + hardcore_enabled ? "Hardcore" : "Softcore"); + rdp_onscreen_message(buffer); + + snprintf(buffer, sizeof(buffer), "%u/%u achievements unlocked", + summary.num_unlocked_achievements, summary.num_core_achievements); + rdp_onscreen_message(buffer); + g_game_loaded = true; +} + +void ra_load_game(const uint8_t *rom, size_t rom_size) { + if (!g_user_logged_in) + return; + + rc_client_begin_identify_and_load_game(g_client, RC_CONSOLE_NINTENDO_64, NULL, + rom, rom_size, load_game_callback, + NULL); +} + +void ra_set_dmem(uint8_t *dmem, size_t dmem_size) { + g_dmem = dmem; + g_dmem_size = dmem_size; +} + +static void achievement_triggered(const rc_client_achievement_t *achievement) { + char buffer[512]; + + snprintf(buffer, sizeof(buffer), "RA unlocked: %s", achievement->title); + rdp_onscreen_message(buffer); +} + +static void game_completed(rc_client_t *client) { + char buffer[512]; + const rc_client_game_t *game = rc_client_get_game_info(client); + + snprintf(buffer, sizeof(buffer), "RA %s: %s", + rc_client_get_hardcore_enabled(client) ? "mastered" : "completed", + game->title); + rdp_onscreen_message(buffer); +} + +static void subset_completed(const rc_client_subset_t *subset, + rc_client_t *client) { + char buffer[512]; + + snprintf(buffer, sizeof(buffer), "RA subset %s: %s", + rc_client_get_hardcore_enabled(client) ? "mastered" : "completed", + subset->title); + rdp_onscreen_message(buffer); +} + +static void event_handler(const rc_client_event_t *event, rc_client_t *client) { + switch (event->type) { + case RC_CLIENT_EVENT_ACHIEVEMENT_TRIGGERED: + achievement_triggered(event->achievement); + break; + case RC_CLIENT_EVENT_LEADERBOARD_STARTED: + break; + case RC_CLIENT_EVENT_LEADERBOARD_FAILED: + break; + case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED: + break; + case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW: + break; + case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE: + break; + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW: + break; + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE: + break; + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE: + break; + case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_SHOW: + break; + case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE: + break; + case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_UPDATE: + break; + case RC_CLIENT_EVENT_LEADERBOARD_SCOREBOARD: + break; + case RC_CLIENT_EVENT_GAME_COMPLETED: + game_completed(client); + break; + case RC_CLIENT_EVENT_SUBSET_COMPLETED: + subset_completed(event->subset, client); + break; + default: + printf("RetroAchievements: Unhandled event %d\n", event->type); + break; + } +} + +void ra_init_client(bool hardcore) { + // Create the client instance (using a global variable simplifies this + // example) + g_client = rc_client_create(read_memory, server_call); + g_game_loaded = false; + g_user_logged_in = false; + g_dmem = NULL; + g_dmem_size = 0; + + // Provide a logging function to simplify debugging + rc_client_enable_logging(g_client, RC_CLIENT_LOG_LEVEL_WARN, log_message); + + rc_client_set_event_handler(g_client, event_handler); + + rc_client_set_hardcore_enabled(g_client, hardcore); +} + +bool ra_get_hardcore() { + if (!g_user_logged_in) + return false; + return rc_client_get_hardcore_enabled(g_client); +} + +void ra_shutdown_client() { + if (g_client) { + // Release resources associated to the client instance + rc_client_destroy(g_client); + g_client = NULL; + } +} + +void ra_do_frame() { + if (!g_game_loaded) + return; + + rc_client_do_frame(g_client); +} + +void ra_do_idle() { + if (!g_game_loaded) + return; + + rc_client_idle(g_client); +} + +size_t ra_state_size() { + if (!g_game_loaded) + return 0; + + return rc_client_progress_size(g_client); +} + +void ra_save_state(uint8_t *state) { + if (!g_game_loaded) + return; + if (rc_client_serialize_progress(g_client, state) != RC_OK) { + printf("RetroAchievements: Failed to serialize progress\n"); + } +} + +void ra_load_state(const uint8_t *state) { + if (!g_game_loaded) + return; + if (rc_client_deserialize_progress(g_client, state) != RC_OK) { + printf("RetroAchievements: Failed to deserialize progress\n"); + } +} diff --git a/retroachievements/retroachievements.h b/retroachievements/retroachievements.h new file mode 100644 index 00000000..833425dd --- /dev/null +++ b/retroachievements/retroachievements.h @@ -0,0 +1,23 @@ +#include +#include +#include + +void ra_init_client(bool hardcore); +void ra_shutdown_client(); +bool ra_get_hardcore(); +void ra_load_game(const uint8_t *rom, size_t rom_size); +void ra_set_dmem(uint8_t *dmem, size_t dmem_size); +void ra_do_frame(); +void ra_do_idle(); +void ra_http_callback(const char *content, size_t content_size, int status_code, + void *callback, void *callback_data); +void ra_logout_user(); +void ra_login_user(const char *username, const char *password, void *userdata); +void ra_login_token_user(const char *username, const char *token, + void *userdata); +bool ra_is_user_logged_in(); +const char *ra_get_username(); +const char *ra_get_token(); +size_t ra_state_size(); +void ra_save_state(uint8_t *state); +void ra_load_state(const uint8_t *state); diff --git a/src/device.rs b/src/device.rs index f7848516..129d5bdc 100644 --- a/src/device.rs +++ b/src/device.rs @@ -12,7 +12,7 @@ use std::arch::x86_64::*; use rand::{Rng, SeedableRng}; -use crate::{cheats, netplay, ui}; +use crate::{cheats, netplay, retroachievements, ui}; use std::{collections::HashMap, fs, io::Read}; pub mod ai; @@ -49,22 +49,27 @@ pub fn run_game(device: &mut Device, rom_contents: Vec, game_settings: ui::g if game_settings.disable_expansion_pak { device.rdram.size = 0x400000; } - if let Some(slot) = game_settings.load_savestate_slot { + if let Some(slot) = game_settings.load_savestate_slot + && !retroachievements::get_hardcore() + { device.ui.storage.save_state_slot = slot; device.load_state = true; } init_rng(device); - cart::rom::init(device, rom_contents); // cart needs to come before rdram + cart::rom::init(device, &rom_contents); // cart needs to come before rdram - // rdram pointer is shared with parallel-rdp + // rdram pointer is shared with parallel-rdp and retroachievements rdram::init(device); ui::video::init(device); ui::audio::init(&mut device.ui, device.ai.freq); ui::input::init(&mut device.ui); + // shows messages, needs to be after video init + retroachievements::load_game(&rom_contents, rom_contents.len()); + mi::init(device); pif::init(device); if device.ui.config.input.emulate_vru && device.netplay.is_none() { @@ -80,7 +85,9 @@ pub fn run_game(device: &mut Device, rom_contents: Vec, game_settings: ui::g ui::storage::init(&mut device.ui, &device.cart.rom); ui::storage::load_saves(&mut device.ui, &mut device.netplay); - cheats::init(device, game_settings.cheats); + if !retroachievements::get_hardcore() { + cheats::init(device, game_settings.cheats); + } cpu::run(device); diff --git a/src/device/cart/rom.rs b/src/device/cart/rom.rs index 9c82433e..375fddff 100644 --- a/src/device/cart/rom.rs +++ b/src/device/cart/rom.rs @@ -133,7 +133,7 @@ pub fn dma_write( device::pi::calculate_cycles(device, 1, length) } -pub fn init(device: &mut device::Device, rom_file: Vec) { +pub fn init(device: &mut device::Device, rom_file: &[u8]) { let now: chrono::DateTime = chrono::Local::now(); device.cart.rtc_timestamp = now.naive_local().and_utc().timestamp(); if let Some(netplay) = &mut device.netplay { @@ -146,7 +146,7 @@ pub fn init(device: &mut device::Device, rom_file: Vec) { device.cart.sc64.cfg[device::cart::sc64::SC64_BOOTLOADER_SWITCH as usize] = 1; - device.cart.rom = rom_file; + device.cart.rom = rom_file.to_vec(); device.cart.pal = is_system_pal(device.cart.rom[0x3E]); set_cic(device); diff --git a/src/device/rdram.rs b/src/device/rdram.rs index 2c2ee091..a66ab3bc 100644 --- a/src/device/rdram.rs +++ b/src/device/rdram.rs @@ -1,4 +1,5 @@ use crate::device; +use crate::retroachievements; use crate::ui; use std::alloc::{Layout, alloc_zeroed}; @@ -151,6 +152,8 @@ pub fn init(device: &mut device::Device) { device.rdram.mem = unsafe { Vec::from_raw_parts(ptr, device.rdram.size as usize, device.rdram.size as usize) }; + retroachievements::set_dmem(device.rdram.mem.as_mut_ptr(), device.rdram.size as usize); + // hack, skip RDRAM initialization device .rdram diff --git a/src/device/vi.rs b/src/device/vi.rs index 5020e917..95f596ba 100644 --- a/src/device/vi.rs +++ b/src/device/vi.rs @@ -1,4 +1,4 @@ -use crate::{cheats, device, netplay, ui}; +use crate::{cheats, device, netplay, retroachievements, ui}; const VI_STATUS_REG: u32 = 0; const VI_ORIGIN_REG: u32 = 1; @@ -128,6 +128,8 @@ pub fn vertical_interrupt_event(device: &mut device::Device) { cheats::execute_cheats(device, device.cheats.cheats.clone()); } + retroachievements::do_frame(); + ui::video::render_frame(); let (mut speed_limiter_toggled, paused) = ui::video::check_callback(device); @@ -147,7 +149,7 @@ pub fn vertical_interrupt_event(device: &mut device::Device) { ui::video::update_screen(); device.vi.vi_counter += 1; - if device.netplay.is_none() && paused { + if device.netplay.is_none() && !retroachievements::get_hardcore() && paused { ui::video::pause_loop(device.vi.frame_time); } diff --git a/src/main.rs b/src/main.rs index 3d9a8608..abc8c1d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; mod cheats; mod device; mod netplay; +mod retroachievements; mod savestates; mod ui; use clap::Parser; @@ -82,6 +83,29 @@ struct Args { help = "Load savestate from slot 0-9 when starting the game" )] load_state: Option, + #[arg( + long = "ra-username", + value_name = "USERNAME", + help = "Username for RetroAchievements" + )] + ra_username: Option, + #[arg( + long = "ra-token", + value_name = "TOKEN", + help = "Token for RetroAchievements" + )] + ra_token: Option, + #[arg( + long = "ra-password", + value_name = "PASSWORD", + help = "Password for RetroAchievements" + )] + ra_password: Option, + #[arg( + long = "ra-hardcore", + help = "Enable Hardcore mode for RetroAchievements" + )] + ra_hardcore: bool, } #[tokio::main(worker_threads = 4)] @@ -134,6 +158,7 @@ async fn main() -> std::io::Result<()> { .unwrap_or_default() }; + retroachievements::init_client(false); //args.ra_hardcore let mut shutdown_tx = None; if let Some(peer_addr) = args.netplay_peer_addr @@ -157,6 +182,17 @@ async fn main() -> std::io::Result<()> { if device.ui.config.emulation.usb { (shutdown_tx, device.ui.usb) = ui::usb::init(); } + + if let Some(username) = args.ra_username { + let (tx, rx) = tokio::sync::oneshot::channel::(); + if let Some(password) = args.ra_password { + retroachievements::login_user(username, password, tx); + } else if let Some(token) = args.ra_token { + retroachievements::login_token_user(username, token, tx); + } + + rx.await.unwrap(); + } } device::run_game( @@ -169,6 +205,7 @@ async fn main() -> std::io::Result<()> { load_savestate_slot: args.load_state, }, ); + retroachievements::shutdown_client(); if device.netplay.is_some() { netplay::close(&mut device); @@ -217,7 +254,9 @@ async fn main() -> std::io::Result<()> { ui::input::bind_input_profile(&mut ui, profile, port); } } else { + retroachievements::init_client(false); gui::app_window(); + retroachievements::shutdown_client(); } Ok(()) diff --git a/src/retroachievements.rs b/src/retroachievements.rs new file mode 100644 index 00000000..e0a40430 --- /dev/null +++ b/src/retroachievements.rs @@ -0,0 +1,304 @@ +include!(concat!(env!("OUT_DIR"), "/retroachievements_bindings.rs")); + +use crate::ui; +use slint::ComponentHandle; + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct RAConfig { + pub username: String, + pub token: String, + pub enabled: bool, + pub hardcore: bool, +} + +#[unsafe(no_mangle)] +pub extern "C" fn store_retroachievements_credentials( + c_username: *const std::ffi::c_char, + c_token: *const std::ffi::c_char, + ctx: *mut std::ffi::c_void, +) { + let tx = unsafe { Box::from_raw(ctx as *mut tokio::sync::oneshot::Sender) }; + + if c_username.is_null() || c_token.is_null() { + tx.send(false).unwrap(); + return; + } + + let file_path = ui::get_dirs().config_dir.join("retroachievements.json"); + let raconfig = if let Ok(ra_config) = std::fs::read(&file_path) + && let Ok(result) = serde_json::from_slice::(ra_config.as_ref()) + { + RAConfig { + username: unsafe { std::ffi::CStr::from_ptr(c_username).to_str().unwrap() }.to_string(), + token: unsafe { std::ffi::CStr::from_ptr(c_token).to_str().unwrap() }.to_string(), + enabled: result.enabled, + hardcore: result.hardcore, + } + } else { + RAConfig { + username: unsafe { std::ffi::CStr::from_ptr(c_username).to_str().unwrap() }.to_string(), + token: unsafe { std::ffi::CStr::from_ptr(c_token).to_str().unwrap() }.to_string(), + enabled: false, + hardcore: false, + } + }; + let f = std::fs::File::create(&file_path).unwrap(); + serde_json::to_writer_pretty(f, &raconfig).unwrap(); + + tx.send(true).unwrap(); +} + +#[unsafe(no_mangle)] +pub extern "C" fn rust_server_call( + c_url: *const std::ffi::c_char, + c_post_data: *const std::ffi::c_char, + c_callback: *mut std::ffi::c_void, + c_callback_data: *mut std::ffi::c_void, +) { + let url = unsafe { std::ffi::CStr::from_ptr(c_url).to_str().unwrap() }; + let client = reqwest::Client::builder() + .user_agent(format!( + "{}/{}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + )) + .build() + .unwrap(); + let task = if !c_post_data.is_null() { + let post_data = unsafe { std::ffi::CStr::from_ptr(c_post_data).to_str().unwrap() }; + let query_string = + reqwest::Url::parse(&format!("http://nothing.com?{}", post_data)).unwrap(); + client + .post(url) + .query( + &query_string + .query_pairs() + .into_owned() + .collect::>(), + ) + .send() + } else { + client.get(url).send() + }; + let callback = c_callback.addr(); + let callback_data = c_callback_data.addr(); + tokio::spawn(async move { + let response = task.await; + match response { + Ok(response) => { + let status = response.status().as_u16() as i32; + match response.text().await { + Ok(text) => { + let c_text = std::ffi::CString::new(text).unwrap(); + unsafe { + ra_http_callback( + c_text.as_ptr(), + c_text.count_bytes(), + status, + callback as *mut std::ffi::c_void, + callback_data as *mut std::ffi::c_void, + ) + }; + } + Err(error) => { + let c_error = std::ffi::CString::new(error.to_string()).unwrap(); + unsafe { + ra_http_callback( + c_error.as_ptr(), + c_error.count_bytes(), + status, + callback as *mut std::ffi::c_void, + callback_data as *mut std::ffi::c_void, + ) + }; + } + } + } + Err(error) => { + let c_error = std::ffi::CString::new(error.to_string()).unwrap(); + unsafe { + ra_http_callback( + c_error.as_ptr(), + c_error.count_bytes(), + 0, + callback as *mut std::ffi::c_void, + callback_data as *mut std::ffi::c_void, + ) + }; + } + } + }); +} + +fn set_current_user_message(app: &ui::gui::AppWindow, rx: tokio::sync::oneshot::Receiver) { + app.set_ra_current_user_message("Logging in...".into()); + let weak_app = app.as_weak(); + tokio::spawn(async move { + rx.await.unwrap(); + weak_app + .upgrade_in_event_loop(move |handle| { + if unsafe { ra_is_user_logged_in() } { + handle.set_ra_current_user_message( + format!("Logged in as {}", handle.get_ra_username()).into(), + ); + } else { + handle.set_ra_current_user_message("Login failed".into()); + } + handle.set_ra_logging_in(false); + }) + .unwrap(); + }); +} + +pub fn ra_window(app: &ui::gui::AppWindow) { + let mut token = String::new(); + if let Ok(ra_config) = std::fs::read(ui::get_dirs().config_dir.join("retroachievements.json")) + && let Ok(result) = serde_json::from_slice::(ra_config.as_ref()) + { + app.set_ra_username(result.username.into()); + app.set_ra_enabled(result.enabled); + app.set_ra_hardcore(result.hardcore); + token = result.token; + } else { + app.set_ra_hardcore(true); + } + + if app.get_ra_enabled() && !app.get_ra_username().is_empty() { + let (tx, rx) = tokio::sync::oneshot::channel::(); + login_token_user(app.get_ra_username().to_string(), token, tx); + set_current_user_message(app, rx); + } else { + app.set_ra_current_user_message("Not currently logged in".into()); + } + + let weak_app2 = app.as_weak(); + app.on_ra_button_clicked(move |password| { + weak_app2 + .upgrade_in_event_loop(move |handle| { + let (tx, rx) = tokio::sync::oneshot::channel::(); + login_user( + handle.get_ra_username().to_string(), + password.to_string(), + tx, + ); + + set_current_user_message(&handle, rx); + }) + .unwrap(); + }); + + app.on_ra_toggled(move |enabled, hardcore| { + let file_path = ui::get_dirs().config_dir.join("retroachievements.json"); + let raconfig = if let Ok(ra_config) = std::fs::read(&file_path) + && let Ok(result) = serde_json::from_slice::(ra_config.as_ref()) + { + if !enabled { + unsafe { ra_logout_user() }; + RAConfig { + username: "".into(), + token: "".into(), + enabled, + hardcore, + } + } else { + RAConfig { + username: result.username, + token: result.token, + enabled, + hardcore, + } + } + } else { + RAConfig { + username: "".into(), + token: "".into(), + enabled, + hardcore, + } + }; + let f = std::fs::File::create(&file_path).unwrap(); + serde_json::to_writer_pretty(f, &raconfig).unwrap(); + }); + + app.on_ra_games_clicked(move || { + open::that_detached("https://retroachievements.org/system/2-nintendo-64/games").unwrap(); + }); +} + +pub fn load_game(rom: &[u8], rom_size: usize) { + unsafe { ra_load_game(rom.as_ptr(), rom_size) }; +} + +pub fn set_dmem(dmem: *mut u8, dmem_size: usize) { + unsafe { ra_set_dmem(dmem, dmem_size) }; +} + +pub fn do_frame() { + unsafe { ra_do_frame() }; +} + +pub fn do_idle() { + unsafe { ra_do_idle() }; +} + +pub fn init_client(hardcore: bool) { + unsafe { ra_init_client(hardcore) }; +} + +pub fn shutdown_client() { + unsafe { ra_shutdown_client() }; +} + +pub fn get_hardcore() -> bool { + unsafe { ra_get_hardcore() } +} + +pub fn login_user(username: String, password: String, tx: tokio::sync::oneshot::Sender) { + unsafe { + let tx_ptr = Box::into_raw(Box::new(tx)) as *mut std::ffi::c_void; + ra_login_user( + std::ffi::CString::new(username).unwrap().as_ptr(), + std::ffi::CString::new(password).unwrap().as_ptr(), + tx_ptr, + ) + }; +} + +pub fn login_token_user(username: String, token: String, tx: tokio::sync::oneshot::Sender) { + unsafe { + let tx_ptr = Box::into_raw(Box::new(tx)) as *mut std::ffi::c_void; + ra_login_token_user( + std::ffi::CString::new(username).unwrap().as_ptr(), + std::ffi::CString::new(token).unwrap().as_ptr(), + tx_ptr, + ) + }; +} + +pub fn is_user_logged_in() -> bool { + unsafe { ra_is_user_logged_in() } +} + +pub fn get_username() -> &'static str { + unsafe { + std::ffi::CStr::from_ptr(ra_get_username()) + .to_str() + .unwrap() + } +} + +pub fn get_token() -> &'static str { + unsafe { std::ffi::CStr::from_ptr(ra_get_token()).to_str().unwrap() } +} + +pub fn state_size() -> usize { + unsafe { ra_state_size() } +} + +pub fn save_state(state: *mut u8) { + unsafe { ra_save_state(state) }; +} + +pub fn load_state(state: *const u8) { + unsafe { ra_load_state(state) }; +} diff --git a/src/savestates.rs b/src/savestates.rs index b49e85c3..dce97dc6 100644 --- a/src/savestates.rs +++ b/src/savestates.rs @@ -1,4 +1,4 @@ -use crate::{cheats, device, ui}; +use crate::{cheats, device, retroachievements, ui}; #[cfg(target_arch = "aarch64")] use device::__m128i; use serde::de::{Deserialize, Deserializer, SeqAccess, Visitor}; @@ -73,6 +73,9 @@ pub fn create_savestate(device: &device::Device) { let mut rdp_state: Vec = vec![0; ui::video::state_size()]; ui::video::save_state(rdp_state.as_mut_ptr()); + let mut ra_state: Vec = vec![0; retroachievements::state_size()]; + retroachievements::save_state(ra_state.as_mut_ptr()); + let data: &[(&[u8], &str)] = &[ (&postcard::to_stdvec(device).unwrap(), "device"), ( @@ -80,6 +83,7 @@ pub fn create_savestate(device: &device::Device) { "saves", ), (&rdp_state, "rdp_state"), + (&ra_state, "ra_state"), ]; let save_path = device.ui.storage.paths.savestate_file_path.clone(); let compressed_file = ui::storage::compress_file(data); @@ -101,6 +105,7 @@ pub fn load_savestate(device: &mut device::Device) { let device_bytes = ui::storage::decompress_file(savestate, "device"); let save_bytes = ui::storage::decompress_file(savestate, "saves"); let rdp_state = ui::storage::decompress_file(savestate, "rdp_state"); + let ra_state = ui::storage::decompress_file(savestate, "ra_state"); if let Ok(state) = postcard::from_bytes::(&device_bytes) { device.ui.storage.saves = postcard::from_bytes(&save_bytes).unwrap(); @@ -198,6 +203,8 @@ pub fn load_savestate(device: &mut device::Device) { ui::audio::init_game_audio(&mut device.ui, device.ai.freq); ui::video::load_state(device, rdp_state.as_ptr()); + retroachievements::load_state(ra_state.as_ptr()); + if device.cheats.enabled { cheats::execute_cheats(device, device.cheats.cheats.clone()); } diff --git a/src/ui/gui.rs b/src/ui/gui.rs index dc66403a..ce64db53 100644 --- a/src/ui/gui.rs +++ b/src/ui/gui.rs @@ -1,3 +1,4 @@ +use crate::retroachievements; use crate::ui; use slint::Model; @@ -299,6 +300,7 @@ fn about_window(app: &AppWindow) { pub fn app_window() { let app = AppWindow::new().unwrap(); about_window(&app); + retroachievements::ra_window(&app); let mut controller_paths; { let game_ui = ui::Ui::new(); @@ -321,6 +323,7 @@ pub fn run_rom( file_path: std::path::PathBuf, game_settings: GameSettings, netplay: Option, + ra_hardcore: bool, weak: slint::Weak, ) { tokio::spawn(async move { @@ -347,6 +350,16 @@ pub fn run_rom( "--cheats", cheats_path.to_str().unwrap(), ]); + } else if retroachievements::is_user_logged_in() { + command.args([ + "--ra-username", + retroachievements::get_username(), + "--ra-token", + retroachievements::get_token(), + ]); + if ra_hardcore { + command.args(["--ra-hardcore"]); + } } for i in 0..4 { @@ -409,6 +422,7 @@ fn open_rom(app: &AppWindow) { let overclock = app.get_overclock_n64_cpu(); let disable_expansion_pak = app.get_disable_expansion_pak(); + let ra_hardcore = app.get_ra_hardcore(); let weak = app.as_weak(); tokio::spawn(async move { @@ -439,6 +453,7 @@ fn open_rom(app: &AppWindow) { load_savestate_slot: None, }, None, + ra_hardcore, weak, ); } diff --git a/src/ui/gui/appwindow.slint b/src/ui/gui/appwindow.slint index da387b23..000fb437 100644 --- a/src/ui/gui/appwindow.slint +++ b/src/ui/gui/appwindow.slint @@ -2,9 +2,10 @@ // SPDX-License-Identifier: MIT import { StandardButton } from "std-widgets.slint"; -import { LocalGame, ControllerConfig, Netplay, Cheats, Settings, About } from "pages.slint"; +import { LocalGame, ControllerConfig, Netplay, Cheats, RetroAchievements, Settings, About } from "pages.slint"; import { SideBar } from "side_bar.slint"; import { SettingsData } from "settings_page.slint"; +import { RAData } from "retroachievements_page.slint"; import { AboutData } from "about_page.slint"; import { ControllerData } from "controller_page.slint"; import { State } from "localgame_page.slint"; @@ -34,8 +35,17 @@ export component AppWindow inherits Window { callback cheats_select_rom_clicked(string); callback cheats_clear_clicked; callback cheat_toggled(string, string, string, bool); + callback ra_button_clicked(string); + callback ra_games_clicked; + callback ra_toggled(bool,bool); in property version <=> AboutData.version; in property has_update <=> AboutData.has_update; + in-out property ra_username <=> RAData.ra_username; + in-out property ra_password <=> RAData.ra_password; + in-out property ra_logging_in <=> RAData.ra_logging_in; + in-out property ra_current_user_message <=> RAData.current_user_message; + in-out property ra_enabled <=> RAData.enabled; + in-out property ra_hardcore <=> RAData.hardcore; in-out property integer_scaling <=> SettingsData.integer_scaling; in-out property fullscreen <=> SettingsData.fullscreen; in-out property widescreen <=> SettingsData.widescreen; @@ -71,6 +81,7 @@ export component AppWindow inherits Window { @tr("Menu" => "Controller Config"), @tr("Menu" => "Netplay"), @tr("Menu" => "Cheats"), + @tr("Menu" => "RetroAchievements"), @tr("Menu" => "Settings"), @tr("Menu" => "About") ]; @@ -117,8 +128,19 @@ export component AppWindow inherits Window { cheat_toggled(game_crc,cheat_name,option,enabled); } } - if(side-bar.current-item == 4): Settings { } - if(side-bar.current-item == 5): About { + if(side-bar.current-item == 4): RetroAchievements { + ra_button_clicked(password) => { + ra_button_clicked(password); + } + ra_toggled(enabled,hardcore) => { + ra_toggled(enabled,hardcore); + } + ra_games_clicked => { + ra_games_clicked(); + } + } + if(side-bar.current-item == 5): Settings { } + if(side-bar.current-item == 6): About { wiki_button_clicked => { wiki_button_clicked(); } diff --git a/src/ui/gui/pages.slint b/src/ui/gui/pages.slint index c4fc418e..cd337fe8 100644 --- a/src/ui/gui/pages.slint +++ b/src/ui/gui/pages.slint @@ -2,5 +2,6 @@ export { LocalGame } from "localgame_page.slint"; export { ControllerConfig } from "controller_page.slint"; export { Netplay } from "netplay_page.slint"; export { Cheats } from "cheat_page.slint"; +export { RetroAchievements } from "retroachievements_page.slint"; export { Settings } from "settings_page.slint"; export { About } from "about_page.slint"; diff --git a/src/ui/gui/retroachievements_page.slint b/src/ui/gui/retroachievements_page.slint new file mode 100644 index 00000000..0bbb413d --- /dev/null +++ b/src/ui/gui/retroachievements_page.slint @@ -0,0 +1,105 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +import { LineEdit, CheckBox, Button, HorizontalBox, VerticalBox } from "std-widgets.slint"; +import { Page } from "page.slint"; + +export global RAData { + in-out property enabled; + in-out property hardcore; + in-out property ra_logging_in; + in-out property ra_username; + in-out property ra_password; + in-out property current_user_message; +} + +export component RetroAchievements inherits Page { + callback ra_button_clicked(string); + callback ra_toggled(bool,bool); + callback ra_games_clicked; + title: @tr("RetroAchievements"); + + VerticalBox { + alignment: start; + HorizontalBox { + alignment: center; + CheckBox { + text: @tr("Enabled"); + checked: RAData.enabled; + toggled => { + RAData.enabled = self.checked; + if (!RAData.enabled) { + RAData.ra_username = ""; + username_widget.text = ""; + password_widget.text = ""; + RAData.current_user_message = "Not currently logged in"; + } + ra_toggled(RAData.enabled,RAData.hardcore); + } + } + } + HorizontalBox { + alignment: center; + CheckBox { + text: @tr("Hardcore mode (not currently supported)"); + checked: RAData.hardcore; + enabled: RAData.enabled; + toggled => { + RAData.hardcore = self.checked; + ra_toggled(RAData.enabled,RAData.hardcore); + } + } + } + HorizontalBox { + alignment: center; + Text { + text: RAData.current_user_message; + } + } + HorizontalBox { + alignment: center; + username_widget := LineEdit { + enabled: RAData.enabled; + placeholder-text: @tr("Username"); + text: RAData.ra_username; + edited(text) => { + RAData.ra_username = text; + } + + } + password_widget := LineEdit { + enabled: RAData.enabled; + input-type: password; + placeholder-text: @tr("Password"); + edited(text) => { + RAData.ra_password = text; + } + } + } + HorizontalBox { + alignment: center; + Button { + text: @tr("Login"); + enabled: RAData.enabled && !RAData.ra_logging_in && RAData.ra_username != "" && RAData.ra_password != ""; + clicked => { + RAData.ra_logging_in = true; + ra_button_clicked(RAData.ra_password); + RAData.ra_password = ""; + password_widget.text = ""; + } + } + } + Rectangle { + height: 20px; + } + HorizontalBox { + alignment: center; + Button { + text: @tr("View Supported Games"); + clicked => { + ra_games_clicked(); + } + } + } + } +} diff --git a/src/ui/netplay.rs b/src/ui/netplay.rs index 83f6c343..fb70519e 100644 --- a/src/ui/netplay.rs +++ b/src/ui/netplay.rs @@ -121,10 +121,9 @@ fn populate_server_names(weak: slin .unwrap(); broadcast_sock.set_broadcast(true).unwrap(); let data: [u8; 1] = [1]; - broadcast_sock + let _ = broadcast_sock .send_to(&data, (std::net::Ipv4Addr::BROADCAST, 45000)) - .await - .unwrap(); + .await; let mut buffer = [0; 1024]; if let Ok(Ok(result)) = tokio::time::timeout( std::time::Duration::from_millis(200), @@ -1108,6 +1107,7 @@ fn setup_wait_window( peer_addr: socket_addr, player_number: player_number as u8, }), + false, weak_app, ); }) diff --git a/src/ui/video.rs b/src/ui/video.rs index 1bb6a54b..e763e6fe 100644 --- a/src/ui/video.rs +++ b/src/ui/video.rs @@ -1,6 +1,6 @@ #![allow(non_snake_case)] include!(concat!(env!("OUT_DIR"), "/parallel_bindings.rs")); -use crate::{device, ui}; +use crate::{device, retroachievements, ui}; pub fn init(device: &mut device::Device) { ui::sdl_init(sdl3_sys::init::SDL_INIT_VIDEO); @@ -140,6 +140,7 @@ pub fn pause_loop(frame_time: f64) { while paused && !frame_advance { std::thread::sleep(std::time::Duration::from_secs_f64(frame_time)); unsafe { sdl3_sys::events::SDL_PumpEvents() }; + retroachievements::do_idle(); let callback = unsafe { rdp_check_callback() }; paused = callback.paused; frame_advance = callback.frame_advance; @@ -153,7 +154,7 @@ pub fn check_callback(device: &mut device::Device) -> (bool, bool) { if device.netplay.is_none() { if callback.save_state { device.save_state = true; - } else if callback.load_state { + } else if callback.load_state && !retroachievements::get_hardcore() { device.load_state = true; } if callback.reset_game { @@ -175,7 +176,7 @@ pub fn check_callback(device: &mut device::Device) -> (bool, bool) { } if device.ui.storage.save_state_slot != callback.save_state_slot { - ui::video::onscreen_message( + onscreen_message( &device.ui, &format!("Switching savestate slot to {}", callback.save_state_slot,), );