diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38bbf22..2210040 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -150,7 +150,7 @@ jobs: matrix: include: - target: aarch64-apple-darwin - arch: mac-aarch64 + arch: macos-aarch64 llvm: ARM64 steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index 12c9ff7..a433769 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Windows: https://github.com/gopher64/gopher64/releases/latest/download/gopher64-windows-x86_64.exe +MacOS: https://github.com/gopher64/gopher64/releases/latest/download/gopher64-macos-aarch64.zip + Linux: https://flathub.org/apps/io.github.gopher64.gopher64 ## wiki @@ -11,7 +13,7 @@ https://github.com/gopher64/gopher64/wiki ## discord -https://discord.gg/9RGXq8W8JQ +[Discord invite link](https://discord.gg/9RGXq8W8JQ) ## controls @@ -19,7 +21,7 @@ Keys are mapped according to [these defaults](https://github.com/gopher64/gopher ## netplay -Gopher64 supports netplay (online play with others) via cloud hosted servers. You can also run the server (https://github.com/gopher64/gopher64-netplay-server) yourself on a LAN. +Gopher64 supports netplay (online play with others) via cloud hosted servers. You can also run the [server](https://github.com/gopher64/gopher64-netplay-server) yourself on a LAN. ## portable mode @@ -35,8 +37,8 @@ flatpak run --filesystem=host:ro io.github.gopher64.gopher64 /path/to/rom.z64 ## building and usage -1. Linux only: install the SDL3 dependencies: https://wiki.libsdl.org/SDL3/README-linux#build-dependencies -2. Install rust: https://www.rust-lang.org/tools/install +1. Linux only: [install the SDL3 dependencies](https://wiki.libsdl.org/SDL3/README-linux#build-dependencies) +2. [Install rust](https://www.rust-lang.org/tools/install) 3. `git clone --recursive https://github.com/gopher64/gopher64.git` 4. `cd gopher64` 5. `cargo build --release` @@ -48,10 +50,12 @@ I am very open to contributions! Please contact me via a GitHub issue or Discord ## license -Gopher64 is licensed under the GPLv3 license. Many portions of gopher64 have been adapted from mupen64plus and/or ares. The license for mupen64plus can be found here: https://github.com/mupen64plus/mupen64plus-core/blob/master/LICENSES. The license for ares can be found here: https://github.com/ares-emulator/ares/blob/master/LICENSE. +Gopher64 is licensed under the GPLv3 license. Many portions of gopher64 have been adapted from mupen64plus and/or ares. The license for mupen64plus can be found [here](https://github.com/mupen64plus/mupen64plus-core/blob/master/LICENSES). The license for ares can be found [here](https://github.com/ares-emulator/ares/blob/master/LICENSE). ## privacy and code signing policy Free code signing for the Windows release is provided by [SignPath.io](https://about.signpath.io), certificate by [SignPath Foundation](https://signpath.org). During online netplay sessions, the server logs your IP address and basic session information (game title and session name) for operational purposes. No additional personal data is collected or stored. + +If you enable the RetroAchievements feature, some data is sent to their systems. Please see their terms [here](https://retroachievements.org/terms). diff --git a/parallel-rdp/interface.cpp b/parallel-rdp/interface.cpp index 1ad225d..c932a8c 100644 --- a/parallel-rdp/interface.cpp +++ b/parallel-rdp/interface.cpp @@ -1,4 +1,5 @@ #include "interface.hpp" +#include "../retroachievements/retroachievements.h" #include "rdp_device.hpp" #include "spirv.hpp" #include "spirv_crt.hpp" @@ -155,8 +156,17 @@ bool sdl_event_filter(void *userdata, SDL_Event *event) { case SDL_SCANCODE_F7: callback.load_state = true; break; + case SDL_SCANCODE_F8: + if (messages.empty()) + SDL_RunOnMainThread(ra_display_inprogress_achievements, nullptr, true); + break; case SDL_SCANCODE_F9: display_challenge_indicator = !display_challenge_indicator; + rdp_onscreen_message( + std::format("Challenge indicators: {}", + display_challenge_indicator ? "ON" : "OFF") + .c_str(), + false); break; case SDL_SCANCODE_F12: callback.reset_game = true; @@ -566,10 +576,16 @@ void rdp_load_state(const uint8_t *state) { memcpy(&rdp_device, state, sizeof(RDP_DEVICE)); } -void rdp_onscreen_message(const char *message) { +static void push_onscreen_message(void *message) { if (messages.empty()) message_timer = SDL_GetTicks() + MESSAGE_TIME; - messages.push({std::string(message), Vulkan::ImageHandle()}); + messages.push({std::string((const char *)message), Vulkan::ImageHandle()}); +} + +void rdp_onscreen_message(const char *message, bool long_message) { + SDL_RunOnMainThread(push_onscreen_message, (void *)message, true); + if (long_message) + SDL_RunOnMainThread(push_onscreen_message, (void *)message, true); } uint32_t pixel_size(uint32_t pixel_type, uint32_t area) { diff --git a/parallel-rdp/interface.hpp b/parallel-rdp/interface.hpp index f1c4aae..da286bf 100644 --- a/parallel-rdp/interface.hpp +++ b/parallel-rdp/interface.hpp @@ -44,7 +44,7 @@ void rdp_update_screen(); void rdp_render_frame(); CALL_BACK rdp_check_callback(); uint64_t rdp_process_commands(); -void rdp_onscreen_message(const char *_message); +void rdp_onscreen_message(const char *message, bool long_message); void rdp_new_processor(GFX_INFO _gfx_info); void rdp_check_framebuffers(uint32_t address, uint32_t length); size_t rdp_state_size(); diff --git a/retroachievements/retroachievements.c b/retroachievements/retroachievements.c index ad8aa3e..5b20a10 100644 --- a/retroachievements/retroachievements.c +++ b/retroachievements/retroachievements.c @@ -7,6 +7,7 @@ #include void rust_server_call(const char *url, const char *post_data, + const char *content_type, rc_client_server_callback_t callback, void *callback_data); void store_retroachievements_credentials(const char *username, @@ -28,7 +29,7 @@ static rc_client_leaderboard_list_t *g_leaderboard_list = 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) + if (address + num_bytes > g_dmem_size) memset(buffer, 0, num_bytes); else memcpy(buffer, &g_dmem[address], num_bytes); @@ -38,7 +39,8 @@ static uint32_t read_memory(uint32_t address, uint8_t *buffer, 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); + rust_server_call(request->url, request->post_data, request->content_type, + callback, callback_data); } void ra_http_callback(const char *content, size_t content_size, int status_code, @@ -75,6 +77,8 @@ static void login_callback(int result, const char *error_message, "RA login failed: %s", error_message); store_retroachievements_credentials(NULL, NULL, userdata); return; + } else { + memset(load_game_error_message, 0, sizeof(load_game_error_message)); } // Login was successful. Capture the token for future logins so we don't have @@ -94,6 +98,8 @@ const char *ra_get_token() { return g_token; } void ra_logout_user() { g_user_logged_in = false; + g_username = NULL; + g_token = NULL; rc_client_logout(g_client); } @@ -124,6 +130,8 @@ static void load_game_callback(int result, const char *error_message, "RA load failed: %s", error_message); notify_load_game(userdata); return; + } else { + memset(load_game_error_message, 0, sizeof(load_game_error_message)); } if (!rc_client_is_processing_required(client)) { @@ -139,8 +147,7 @@ static void load_game_callback(int result, const char *error_message, void ra_welcome() { if (strlen(load_game_error_message) > 0) { - rdp_onscreen_message(load_game_error_message); - rdp_onscreen_message(load_game_error_message); // show it a bit longer + rdp_onscreen_message(load_game_error_message, true); } if (!g_game_loaded) return; @@ -163,8 +170,7 @@ void ra_welcome() { snprintf(buffer + message_length, sizeof(buffer) - message_length, "Game has no achievements"); } - rdp_onscreen_message(buffer); - rdp_onscreen_message(buffer); // show it a bit longer + rdp_onscreen_message(buffer, true); } void ra_load_game(const uint8_t *rom, size_t rom_size, void *userdata) { @@ -188,14 +194,14 @@ static void leaderboard_submitted(const rc_client_leaderboard_t *leaderboard) { snprintf(buffer, sizeof(buffer), "Leaderboard submitted: %s - %s", leaderboard->title, leaderboard->tracker_value); - rdp_onscreen_message(buffer); + rdp_onscreen_message(buffer, false); } static void achievement_triggered(const rc_client_achievement_t *achievement) { char buffer[512]; snprintf(buffer, sizeof(buffer), "Unlocked: %s", achievement->title); - rdp_onscreen_message(buffer); + rdp_onscreen_message(buffer, false); } static void game_completed(rc_client_t *client) { @@ -205,7 +211,7 @@ static void game_completed(rc_client_t *client) { snprintf(buffer, sizeof(buffer), "%s: %s", rc_client_get_hardcore_enabled(client) ? "Mastered" : "Completed", game->title); - rdp_onscreen_message(buffer); + rdp_onscreen_message(buffer, false); } static void subset_completed(const rc_client_subset_t *subset, @@ -215,7 +221,7 @@ static void subset_completed(const rc_client_subset_t *subset, snprintf(buffer, sizeof(buffer), "Subset %s: %s", rc_client_get_hardcore_enabled(client) ? "mastered" : "completed", subset->title); - rdp_onscreen_message(buffer); + rdp_onscreen_message(buffer, false); } static void server_error(const rc_client_server_error_t *server_error) { @@ -223,10 +229,13 @@ static void server_error(const rc_client_server_error_t *server_error) { snprintf(buffer, sizeof(buffer), "RA server error: %s", server_error->error_message); - rdp_onscreen_message(buffer); + rdp_onscreen_message(buffer, false); } static const char *get_leaderboard_title(const char *display) { + if (g_leaderboard_list == NULL) + return NULL; + for (uint32_t i = 0; i < g_leaderboard_list->num_buckets; i++) { for (uint32_t j = 0; j < g_leaderboard_list->buckets[i].num_leaderboards; j++) { @@ -314,7 +323,6 @@ void ra_init_client(bool hardcore, bool challenge, bool leaderboard) { g_user_logged_in = false; g_dmem = NULL; g_dmem_size = 0; - memset(load_game_error_message, 0, sizeof(load_game_error_message)); // Provide a logging function to simplify debugging rc_client_enable_logging(g_client, RC_CLIENT_LOG_LEVEL_WARN, log_message); @@ -383,3 +391,35 @@ void ra_load_state(const uint8_t *state, size_t state_size) { printf("RetroAchievements: Failed to deserialize progress\n"); } } + +void ra_display_inprogress_achievements(void *userdata) { + if (!g_game_loaded) + return; + + rc_client_achievement_list_t *list = rc_client_create_achievement_list( + g_client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, + RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE); + + char buffer[1024] = {0}; + size_t buffer_length = 0; + + for (uint32_t i = 0; i < list->num_buckets; i++) { + if (list->buckets[i].bucket_type == RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED) { + for (uint32_t j = 0; j < list->buckets[i].num_achievements; j++) { + const rc_client_achievement_t *achievement = + list->buckets[i].achievements[j]; + if (achievement->measured_percent > 0.0f) { + if (buffer_length < sizeof(buffer) - 1) { + buffer_length += snprintf( + buffer + buffer_length, sizeof(buffer) - buffer_length, + "%s: %s\n", achievement->title, achievement->measured_progress); + } + } + } + } + } + if (buffer_length > 0) { + rdp_onscreen_message(buffer, false); + } + rc_client_destroy_achievement_list(list); +} diff --git a/retroachievements/retroachievements.h b/retroachievements/retroachievements.h index 5c2e532..bc1df5a 100644 --- a/retroachievements/retroachievements.h +++ b/retroachievements/retroachievements.h @@ -22,3 +22,11 @@ const char *ra_get_token(); size_t ra_state_size(); void ra_save_state(uint8_t *state, size_t state_size); void ra_load_state(const uint8_t *state, size_t state_size); + +#ifdef __cplusplus +extern "C" { +#endif +void ra_display_inprogress_achievements(void *userdata); +#ifdef __cplusplus +} +#endif diff --git a/src/cheats.rs b/src/cheats.rs index d81b8f9..88cc730 100644 --- a/src/cheats.rs +++ b/src/cheats.rs @@ -71,7 +71,7 @@ pub fn init( if !device.cheats.cheats.is_empty() { device.cheats.enabled = true; - ui::video::onscreen_message(&device.ui, "Cheats enabled"); + ui::video::onscreen_message(&device.ui, "Cheats enabled", false); } } diff --git a/src/device/controller.rs b/src/device/controller.rs index 3b279ff..f8a4134 100644 --- a/src/device/controller.rs +++ b/src/device/controller.rs @@ -190,21 +190,21 @@ pub fn pak_switch_event(device: &mut device::Device) { write: device::controller::mempak::write, pak_type: new_pak_type, }); - ui::video::onscreen_message(&device.ui, "MemPak selected"); + ui::video::onscreen_message(&device.ui, "MemPak selected", false); } else if new_pak_type == PakType::RumblePak { channel.pak_handler = Some(device::controller::PakHandler { read: device::controller::rumble::read, write: device::controller::rumble::write, pak_type: new_pak_type, }); - ui::video::onscreen_message(&device.ui, "RumblePak selected"); + ui::video::onscreen_message(&device.ui, "RumblePak selected", false); } else if new_pak_type == PakType::TransferPak { channel.pak_handler = Some(device::controller::PakHandler { read: device::controller::transferpak::read, write: device::controller::transferpak::write, pak_type: new_pak_type, }); - ui::video::onscreen_message(&device.ui, "TransferPak selected"); + ui::video::onscreen_message(&device.ui, "TransferPak selected", false); } channel.change_pak = PakType::None; } diff --git a/src/device/cpu.rs b/src/device/cpu.rs index 1b209dc..55f46bd 100644 --- a/src/device/cpu.rs +++ b/src/device/cpu.rs @@ -230,6 +230,7 @@ pub fn init(device: &mut device::Device) { ui::video::onscreen_message( &device.ui, "Overclocking enabled, setting clock rate to 125 MHz", + false, ); 125000000 }; diff --git a/src/device/exceptions.rs b/src/device/exceptions.rs index 7ae8685..215ca7b 100644 --- a/src/device/exceptions.rs +++ b/src/device/exceptions.rs @@ -166,7 +166,7 @@ pub fn reset_event(device: &mut device::Device) { device::pif::reset_pif(device, true); - ui::video::onscreen_message(&device.ui, "Game reset"); + ui::video::onscreen_message(&device.ui, "Game reset", false); } fn exception_general(device: &mut device::Device, vector_offset: u32) { diff --git a/src/device/vi.rs b/src/device/vi.rs index 6096b7e..0c4ec1c 100644 --- a/src/device/vi.rs +++ b/src/device/vi.rs @@ -151,7 +151,7 @@ pub fn vertical_interrupt_event(device: &mut device::Device) { if device.netplay.is_none() && paused { if retroachievements::get_hardcore() { - ui::video::onscreen_message(&device.ui, "Cannot pause in RA hardcore mode"); + ui::video::onscreen_message(&device.ui, "Cannot pause in RA hardcore mode", false); } else { ui::video::pause_loop(device.vi.frame_time); } diff --git a/src/main.rs b/src/main.rs index be3cd0f..ff78b6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -207,6 +207,8 @@ async fn main() -> std::io::Result<()> { retroachievements::login_user(username, password, tx); } else if let Some(token) = args.ra_token { retroachievements::login_token_user(username, token, tx); + } else { + tx.send(false).unwrap(); } rx.await.unwrap(); diff --git a/src/netplay.rs b/src/netplay.rs index 589efe2..6c28c86 100644 --- a/src/netplay.rs +++ b/src/netplay.rs @@ -146,7 +146,7 @@ pub fn get_input(device: &mut device::Device, channel: usize) -> ui::input::Inpu .remove(&netplay.player_data[channel].count); if std::time::Instant::now() > timeout { - ui::video::onscreen_message(&device.ui, "Lost connection to netplay server"); + ui::video::onscreen_message(&device.ui, "Lost connection to netplay server", false); input = Some(InputEvent { input: 0, plugin: 0, @@ -191,13 +191,16 @@ fn process_incoming(netplay: &mut Netplay, ui: &ui::Ui) { } if current_status != netplay.status { if ((current_status & 0x1) ^ (netplay.status & 0x1)) != 0 { - ui::video::onscreen_message(ui, "Netplay desync detected"); - ui::video::onscreen_message(ui, "Netplay desync detected"); // queue this message twice to ensure they see it + ui::video::onscreen_message(ui, "Netplay desync detected", true); } for dis in 1..5 { if ((current_status & (0x1 << dis)) ^ (netplay.status & (0x1 << dis))) != 0 { - ui::video::onscreen_message(ui, &format!("Player {dis} disconnected")); + ui::video::onscreen_message( + ui, + &format!("Player {dis} disconnected"), + false, + ); } } netplay.status = current_status; diff --git a/src/retroachievements.rs b/src/retroachievements.rs index 4b2e5a0..e4923fc 100644 --- a/src/retroachievements.rs +++ b/src/retroachievements.rs @@ -3,6 +3,17 @@ include!(concat!(env!("OUT_DIR"), "/retroachievements_bindings.rs")); use crate::ui; use slint::ComponentHandle; +static WEB_CLIENT: std::sync::LazyLock = std::sync::LazyLock::new(|| { + reqwest::Client::builder() + .user_agent(format!( + "{}/{}", + env!("CARGO_PKG_NAME"), + env!("GIT_DESCRIBE") + )) + .build() + .unwrap() +}); + #[derive(serde::Serialize, serde::Deserialize)] pub struct RAConfig { pub username: String, @@ -64,33 +75,24 @@ pub extern "C" fn store_retroachievements_credentials( pub extern "C" fn rust_server_call( c_url: *const std::ffi::c_char, c_post_data: *const std::ffi::c_char, + c_content_type: *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!("GIT_DESCRIBE") - )) - .build() - .unwrap(); + let url = unsafe { std::ffi::CStr::from_ptr(c_url).to_str().unwrap() }.to_string(); + 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 + let post_data = + unsafe { std::ffi::CStr::from_ptr(c_post_data).to_str().unwrap() }.to_string(); + let content_type = + unsafe { std::ffi::CStr::from_ptr(c_content_type).to_str().unwrap() }.to_string(); + WEB_CLIENT .post(url) - .query( - &query_string - .query_pairs() - .into_owned() - .collect::>(), - ) + .body(post_data) + .header(reqwest::header::CONTENT_TYPE, content_type) .send() } else { - client.get(url).send() + WEB_CLIENT.get(url).send() }; let callback = c_callback.addr(); let callback_data = c_callback_data.addr(); diff --git a/src/savestates.rs b/src/savestates.rs index 3e9a165..b68047e 100644 --- a/src/savestates.rs +++ b/src/savestates.rs @@ -96,12 +96,17 @@ pub fn create_savestate(device: &device::Device) { "Savestate created in slot {}", device.ui.storage.save_state_slot ), + false, ); } pub fn load_savestate(device: &mut device::Device) { if retroachievements::get_hardcore() { - ui::video::onscreen_message(&device.ui, "Cannot load savestate in RA hardcore mode"); + ui::video::onscreen_message( + &device.ui, + "Cannot load savestate in RA hardcore mode", + false, + ); return; } let savestate = std::fs::read(&device.ui.storage.paths.savestate_file_path); @@ -217,6 +222,7 @@ pub fn load_savestate(device: &mut device::Device) { "Savestate loaded from slot {}", device.ui.storage.save_state_slot ), + false, ); } else { ui::video::onscreen_message( @@ -225,6 +231,7 @@ pub fn load_savestate(device: &mut device::Device) { "Failed to load savestate from slot {}", device.ui.storage.save_state_slot ), + false, ); } } else { @@ -234,6 +241,7 @@ pub fn load_savestate(device: &mut device::Device) { "Could not find savestate in slot {}", device.ui.storage.save_state_slot ), + false, ); } } diff --git a/src/ui/audio.rs b/src/ui/audio.rs index f916385..38f53b4 100644 --- a/src/ui/audio.rs +++ b/src/ui/audio.rs @@ -53,7 +53,11 @@ pub fn lower_audio_volume(ui: &mut ui::Ui) { } sdl3_sys::audio::SDL_SetAudioStreamGain(ui.audio.audio_stream, ui.audio.gain); } - ui::video::onscreen_message(ui, &format!("Audio volume: {:.0}%", ui.audio.gain * 100.0)); + ui::video::onscreen_message( + ui, + &format!("Audio volume: {:.0}%", ui.audio.gain * 100.0), + false, + ); } pub fn raise_audio_volume(ui: &mut ui::Ui) { @@ -64,7 +68,11 @@ pub fn raise_audio_volume(ui: &mut ui::Ui) { } sdl3_sys::audio::SDL_SetAudioStreamGain(ui.audio.audio_stream, ui.audio.gain); } - ui::video::onscreen_message(ui, &format!("Audio volume: {:.0}%", ui.audio.gain * 100.0)); + ui::video::onscreen_message( + ui, + &format!("Audio volume: {:.0}%", ui.audio.gain * 100.0), + false, + ); } fn adjust_audio_frequency(device: &device::Device, frequency: f32) { diff --git a/src/ui/video.rs b/src/ui/video.rs index 797dac3..90c1fee 100644 --- a/src/ui/video.rs +++ b/src/ui/video.rs @@ -183,7 +183,8 @@ pub fn check_callback(device: &mut device::Device) -> (bool, bool) { if device.ui.storage.save_state_slot != callback.save_state_slot { onscreen_message( &device.ui, - &format!("Switching savestate slot to {}", callback.save_state_slot,), + &format!("Switching savestate slot to {}", callback.save_state_slot), + false, ); device.ui.storage.save_state_slot = callback.save_state_slot; device @@ -215,8 +216,13 @@ pub fn check_framebuffers(address: u32, length: u32) { unsafe { rdp_check_framebuffers(address, length) } } -pub fn onscreen_message(_ui: &ui::Ui, message: &str) { - unsafe { rdp_onscreen_message(std::ffi::CString::new(message).unwrap().as_ptr()) }; +pub fn onscreen_message(_ui: &ui::Ui, message: &str, long_message: bool) { + unsafe { + rdp_onscreen_message( + std::ffi::CString::new(message).unwrap().as_ptr(), + long_message, + ) + }; } pub fn draw_text(