finish up RA support (#745)

* finish up RA support

* more

* capture some corner cases

* fix post data

* more

* more

* fix range

* more

* more

* more

* more

* show in-progress achievements

* more

* more

* more

* more
This commit is contained in:
Logan McNaughton
2026-04-15 12:57:52 +02:00
committed by GitHub
parent d12f16fe29
commit b40ec6940f
17 changed files with 155 additions and 57 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
matrix:
include:
- target: aarch64-apple-darwin
arch: mac-aarch64
arch: macos-aarch64
llvm: ARM64
steps:
- uses: actions/checkout@v6
+9 -5
View File
@@ -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).
+18 -2
View File
@@ -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) {
+1 -1
View File
@@ -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();
+52 -12
View File
@@ -7,6 +7,7 @@
#include <string.h>
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);
}
+8
View File
@@ -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
+1 -1
View File
@@ -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);
}
}
+3 -3
View File
@@ -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;
}
+1
View File
@@ -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
};
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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);
}
+2
View File
@@ -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();
+7 -4
View File
@@ -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;
+22 -20
View File
@@ -3,6 +3,17 @@ include!(concat!(env!("OUT_DIR"), "/retroachievements_bindings.rs"));
use crate::ui;
use slint::ComponentHandle;
static WEB_CLIENT: std::sync::LazyLock<reqwest::Client> = 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::<Vec<(String, String)>>(),
)
.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();
+9 -1
View File
@@ -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,
);
}
}
+10 -2
View File
@@ -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) {
+9 -3
View File
@@ -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(