Files
ARMSX1/frontend/achievements.cpp
T

1819 lines
68 KiB
C++

/*
ARMSX — RetroAchievements (rcheevos) integration. See frontend/achievements.h for the
threading contract and the softcore-only stance.
*/
#include "achievements.h"
#include "ra_ua.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <deque>
#include <fstream>
#include <map>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
extern "C" {
#include "../psx/psx.h"
#include "../psx/dev/ram.h"
#include "../psx/dev/scratchpad.h"
#include "../psx/dev/cdrom/cdrom.h"
#include "../psx/dev/cdrom/disc.h"
#include "config.h"
}
extern "C" {
#include "rc_client.h"
#include "rc_consoles.h"
#include "rc_error.h"
#include "rc_hash.h"
}
#if defined(__ANDROID__)
#include <android/log.h>
#define ARMSX_ACH_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "ARMSX-RA", __VA_ARGS__)
#define ARMSX_ACH_LOGW(...) __android_log_print(ANDROID_LOG_WARN, "ARMSX-RA", __VA_ARGS__)
#define ARMSX_ACH_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "ARMSX-RA", __VA_ARGS__)
#else
#define ARMSX_ACH_LOGI(...) ((void)0)
#define ARMSX_ACH_LOGW(...) ((void)0)
#define ARMSX_ACH_LOGE(...) ((void)0)
#endif
namespace {
// --- Constants -----------------------------------------------------------------------------
// RA's console id for the original PlayStation. NOT the PS2 id the ARMSX2 integration uses —
// getting this wrong makes every hash miss the database and every game look unsupported.
constexpr uint32_t kConsoleId = RC_CONSOLE_PLAYSTATION;
static_assert(kConsoleId == 12u, "RetroAchievements PlayStation console id must be 12");
// RA's PS1 memory map (rcheevos consoleinfo.c): main RAM is the first 2 MiB, then the 1 KiB
// scratchpad. Everything above that is not addressable by an achievement.
constexpr uint32_t kRaMainRamSize = 0x200000u;
constexpr uint32_t kRaScratchpadSize = 0x400u;
constexpr uint32_t kRaExposedSize = kRaMainRamSize + kRaScratchpadSize;
constexpr int kServerCallTimeoutMs = 30000;
constexpr int kLoginTimeoutMs = 45000;
// --- State ---------------------------------------------------------------------------------
// Everything rc_client touches is behind this. Recursive because rc_client invokes our event
// handler (and the read-memory callback) from inside calls we make while already holding it.
std::recursive_mutex g_lock;
rc_client_t* g_client = nullptr;
bool g_startup_done = false;
bool g_shutting_down = false;
psx_t* g_psx = nullptr;
armsx_ach_http_fn g_http_handler = nullptr;
void* g_http_user = nullptr;
armsx_ach_sound_fn g_sound_handler = nullptr;
void* g_sound_user = nullptr;
armsx_ach_notify_fn g_notify_handler = nullptr;
void* g_notify_user = nullptr;
std::string g_rich_presence;
std::string g_game_hash;
bool g_has_achievements = false;
// Deferred work the emulation-thread pump picks up: a token login restored from the store and a
// game load. Both do network / disc I/O that must not run on whichever thread asked for it.
bool g_pending_token_login = false;
bool g_pending_game_load = false;
bool g_game_load_in_flight = false;
std::string g_user_agent;
// --- Persisted store -----------------------------------------------------------------------
//
// <pref>/achievements.ini, one key=value per line. Deliberately NOT settings.toml: the RA auth
// token is not something to leave in the file a user pastes into a bug report, and the TOML
// loader in frontend/config.c is read-only anyway.
//
// There is no HardcoreMode key. Softcore is not a stored preference here, it is the only mode.
std::map<std::string, std::string> g_store;
bool g_store_loaded = false;
std::string StorePath() {
const char* pref = psxe_cfg_get_pref_path();
if (!pref || !*pref) {
return {};
}
std::string path(pref);
if (path.back() != '/' && path.back() != '\\') {
path.push_back('/');
}
path += "achievements.ini";
return path;
}
void StoreLoad() {
if (g_store_loaded) {
return;
}
// Latch only once a pref path actually exists. Marking the store loaded while it is still
// unresolved would pin an empty map for the rest of the process and silently lose the saved
// login — the Android UI can poll before any pref path has been handed in.
const std::string path = StorePath();
if (path.empty()) {
return;
}
g_store_loaded = true;
std::ifstream file(path);
if (!file) {
return;
}
std::string line;
while (std::getline(file, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (line.empty() || line[0] == '#') {
continue;
}
const std::size_t split = line.find('=');
if (split == std::string::npos) {
continue;
}
g_store[line.substr(0, split)] = line.substr(split + 1);
}
}
void StoreSave() {
const std::string path = StorePath();
if (path.empty()) {
return;
}
std::ofstream file(path, std::ios::trunc);
if (!file) {
ARMSX_ACH_LOGW("could not write %s", path.c_str());
return;
}
file << "# ARMSX RetroAchievements state. Contains an auth token - do not share.\n";
for (const auto& entry : g_store) {
file << entry.first << '=' << entry.second << '\n';
}
}
std::string StoreGet(const char* key, const char* fallback = "") {
StoreLoad();
const auto it = g_store.find(key);
return it == g_store.end() ? std::string(fallback) : it->second;
}
bool StoreGetBool(const char* key, bool fallback) {
const std::string value = StoreGet(key, fallback ? "1" : "0");
return value == "1" || value == "true";
}
int StoreGetInt(const char* key, int fallback) {
const std::string value = StoreGet(key);
if (value.empty()) {
return fallback;
}
try {
return std::stoi(value);
} catch (...) {
return fallback;
}
}
void StoreSet(const char* key, const std::string& value) {
StoreLoad();
g_store[key] = value;
StoreSave();
}
void StoreSetBool(const char* key, bool value) {
StoreSet(key, value ? "1" : "0");
}
void StoreSetInt(const char* key, int value) {
StoreSet(key, std::to_string(value));
}
void StoreErase(const char* key) {
StoreLoad();
g_store.erase(key);
StoreSave();
}
// --- HTTP request queue --------------------------------------------------------------------
//
// rc_client hands us a request and a callback it wants invoked with the response. The transport
// blocks, so each request gets a detached worker; the finished response is parked here and
// handed back to rc_client from Poll(), which always runs on a thread holding g_lock. That
// keeps rcheevos strictly single-threaded, which is what RC_NO_THREADS assumes.
struct PendingResponse {
rc_client_server_callback_t callback = nullptr;
void* callback_data = nullptr;
uint64_t generation = 0;
armsx_ach_http_response response;
};
std::mutex g_http_mutex;
std::deque<PendingResponse> g_http_done;
int g_http_in_flight = 0;
// Bumped whenever the client is destroyed. callback_data points into rc_client's arena, so a
// response that lands after a shutdown must be dropped, not delivered — otherwise closing a game
// with a request still on the wire and then opening the RetroAchievements screen again would
// hand a fresh client's pump a callback into freed memory.
uint64_t g_http_generation = 0;
void HttpEnqueue(const std::string& url, const std::string& post_data, bool has_post,
const std::string& content_type, rc_client_server_callback_t callback,
void* callback_data) {
armsx_ach_http_fn handler = nullptr;
void* user = nullptr;
uint64_t generation = 0;
{
std::lock_guard<std::recursive_mutex> lock(g_lock);
handler = g_http_handler;
user = g_http_user;
}
{
std::lock_guard<std::mutex> lock(g_http_mutex);
generation = g_http_generation;
}
if (!handler) {
// No transport installed (non-Android host, or too early). Answer immediately with a
// client error so rc_client's state machine unwinds instead of waiting forever.
PendingResponse pending;
pending.callback = callback;
pending.callback_data = callback_data;
pending.generation = generation;
pending.response.status_code = -1;
std::lock_guard<std::mutex> lock(g_http_mutex);
g_http_done.push_back(std::move(pending));
return;
}
{
std::lock_guard<std::mutex> lock(g_http_mutex);
++g_http_in_flight;
}
std::thread worker([handler, user, url, post_data, has_post, content_type, callback,
callback_data, generation]() {
PendingResponse pending;
pending.callback = callback;
pending.callback_data = callback_data;
pending.generation = generation;
handler(url.c_str(), has_post ? post_data.c_str() : nullptr,
content_type.empty() ? nullptr : content_type.c_str(),
armsx_ach_user_agent(), kServerCallTimeoutMs, &pending.response, user);
std::lock_guard<std::mutex> lock(g_http_mutex);
g_http_done.push_back(std::move(pending));
--g_http_in_flight;
});
worker.detach();
}
// Hand every finished response back to rc_client. Caller must hold g_lock.
void HttpPoll() {
for (;;) {
PendingResponse pending;
{
std::lock_guard<std::mutex> lock(g_http_mutex);
if (g_http_done.empty()) {
return;
}
pending = std::move(g_http_done.front());
g_http_done.pop_front();
if (pending.generation != g_http_generation) {
continue; // outlived the client it belonged to
}
}
if (!pending.callback) {
continue;
}
const bool is_error = pending.response.status_code <= 0;
const bool is_retryable = is_error && pending.response.status_code == -2; // HttpClient timeout
rc_api_server_response_t server_response{};
server_response.http_status_code = is_error
? (is_retryable ? RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR
: RC_API_SERVER_RESPONSE_CLIENT_ERROR)
: pending.response.status_code;
server_response.body = pending.response.body.c_str();
server_response.body_length = pending.response.body.size();
pending.callback(&server_response, pending.callback_data);
}
}
bool HttpBusy() {
std::lock_guard<std::mutex> lock(g_http_mutex);
return g_http_in_flight > 0 || !g_http_done.empty();
}
// --- CHD cdreader --------------------------------------------------------------------------
//
// rcheevos' own cdreader understands .cue / .gdi / raw .bin / .iso, and the RA hash database was
// generated with it, so those formats deliberately keep using it untouched. It has no CHD
// support, which is the format most PS1 users actually have — this bridge routes .chd through
// ARMSX's own disc layer instead. Sector semantics mirror cdreader_determine_sector_size():
// libchdr always hands back a raw 2352-byte sector, so all this has to get right is whether the
// user data starts 16 or 24 bytes in, which "CD001" at offset 25 of sector 16 settles.
constexpr int kChdSectorSize = CD_SECTOR_SIZE;
// User data per sector, the same figure the stock cdreader uses (raw_data_size). Mode 1 puts it
// 16 bytes in, mode 2 form 1 24 bytes in; either way it is 2048 bytes long.
constexpr size_t kChdSectorDataSize = 2048;
struct ChdTrack {
psx_disc_t* disc = nullptr;
uint32_t first_sector = 0;
int header_size = 16;
};
void DestroyDisc(psx_disc_t* disc) {
if (!disc) {
return;
}
// psx_disc_destroy() calls disc->destroy unconditionally, and a failed open leaves it null
// (psx_disc_close is declared but has no implementation, so it is not an option here).
if (disc->destroy) {
psx_disc_destroy(disc);
} else {
free(disc);
}
}
void* RC_CCONV ChdOpenTrack(const char* path, uint32_t track, const rc_hash_iterator_t* /*iterator*/) {
// Only the first data track matters for the PS1 hash; rc_hash_psx never asks for another.
if (track != 1 && track != RC_HASH_CDTRACK_FIRST_DATA) {
return nullptr;
}
psx_disc_t* disc = psx_disc_create();
if (!disc) {
return nullptr;
}
if (psx_disc_open(disc, path) == CDT_ERROR) {
DestroyDisc(disc);
return nullptr;
}
ChdTrack* handle = new ChdTrack();
handle->disc = disc;
handle->first_sector = static_cast<uint32_t>(std::max(0, psx_disc_get_track_lba(disc, 1)));
uint8_t sector[kChdSectorSize];
if (psx_disc_read(disc, handle->first_sector + 16u, sector) == TS_DATA) {
handle->header_size = std::memcmp(&sector[25], "CD001", 5) == 0 ? 24 : 16;
}
return handle;
}
size_t RC_CCONV ChdReadSector(void* track_handle, uint32_t sector, void* buffer, size_t requested_bytes) {
ChdTrack* handle = static_cast<ChdTrack*>(track_handle);
if (!handle || !handle->disc || !buffer) {
return 0;
}
uint8_t* out = static_cast<uint8_t*>(buffer);
size_t total = 0;
while (requested_bytes > 0) {
uint8_t raw[kChdSectorSize];
if (psx_disc_read(handle->disc, sector, raw) != TS_DATA) {
return total;
}
const size_t chunk = std::min(requested_bytes, kChdSectorDataSize);
std::memcpy(out, raw + handle->header_size, chunk);
out += chunk;
total += chunk;
requested_bytes -= chunk;
++sector;
}
return total;
}
void RC_CCONV ChdCloseTrack(void* track_handle) {
ChdTrack* handle = static_cast<ChdTrack*>(track_handle);
if (!handle) {
return;
}
DestroyDisc(handle->disc);
delete handle;
}
uint32_t RC_CCONV ChdFirstTrackSector(void* track_handle) {
const ChdTrack* handle = static_cast<const ChdTrack*>(track_handle);
return handle ? handle->first_sector : 0u;
}
bool PathIsChd(const char* path) {
if (!path) {
return false;
}
const std::size_t length = std::strlen(path);
if (length < 4) {
return false;
}
const char* ext = path + length - 4;
return (ext[0] == '.') &&
(ext[1] == 'c' || ext[1] == 'C') &&
(ext[2] == 'h' || ext[2] == 'H') &&
(ext[3] == 'd' || ext[3] == 'D');
}
std::string HashDiscImage(const char* path) {
if (!path || !*path) {
return {};
}
rc_hash_iterator_t iterator;
rc_hash_initialize_iterator(&iterator, path, nullptr, 0);
if (PathIsChd(path)) {
iterator.callbacks.cdreader.open_track = nullptr;
iterator.callbacks.cdreader.open_track_iterator = ChdOpenTrack;
iterator.callbacks.cdreader.read_sector = ChdReadSector;
iterator.callbacks.cdreader.close_track = ChdCloseTrack;
iterator.callbacks.cdreader.first_track_sector = ChdFirstTrackSector;
}
char hash[33] = {};
const int ok = rc_hash_generate(hash, kConsoleId, &iterator);
rc_hash_destroy_iterator(&iterator);
if (!ok || !hash[0]) {
ARMSX_ACH_LOGW("could not hash %s", path);
return {};
}
return std::string(hash);
}
// --- Notice queue --------------------------------------------------------------------------
//
// The one route out of this module to something the user can see. rc_client's callbacks (game
// loaded, achievement triggered, login rejected) run from HttpPoll(), i.e. on whichever thread
// happened to be pumping — the emulation thread most of the time, but the Java IO thread driving
// armsx_ach_login() whenever a login is in flight, and the caller of armsx_ach_shutdown()
// otherwise. Calling the host from there would mean a JNI call from an arbitrary thread while
// g_lock is held, so instead every notice is parked here and DrainNotices() delivers it from the
// emulation-thread pump with no lock held. Same shape as the psxe_host_* request parking in
// frontend/main.cpp.
struct PendingNotice {
int kind = ARMSX_ACH_NOTICE_INFO;
std::string key;
std::string title;
std::string detail;
std::string image_url;
int duration_ms = 5000;
};
std::mutex g_notice_mutex;
std::deque<PendingNotice> g_notice_queue;
std::chrono::steady_clock::time_point g_notice_next_at{};
// The host stacks toasts, so a burst no longer costs anything but screen space — but handing it
// six at once still means five of them share one fade-out and read as a wall. They are released
// one every kNoticeSpacingMs instead, which is short enough that a combo unlock still feels
// immediate and long enough that each one arrives as its own event.
constexpr int kNoticeSpacingMs = 600;
// A hard cap so a disconnect storm cannot grow the queue without bound. Sixteen is far more than
// will ever be readable anyway.
constexpr size_t kNoticeQueueMax = 16;
// Park a note. Caller holds g_lock (the store reads below need it).
void QueueNotice(int kind, std::string key, std::string title,
std::string detail = std::string(), std::string image_url = std::string(),
int duration_ms = 0) {
if (title.empty()) {
return;
}
if (key.empty()) {
key = "ra_notice";
}
PendingNotice notice;
notice.kind = kind;
notice.duration_ms = duration_ms > 0
? duration_ms
: std::clamp(StoreGetInt("NotificationsDuration", 5), 3, 30) * 1000;
notice.key = std::move(key);
notice.title = std::move(title);
notice.detail = std::move(detail);
notice.image_url = std::move(image_url);
std::lock_guard<std::mutex> lock(g_notice_mutex);
// Collapse against anything still waiting under the same key: a leaderboard that starts and
// submits before the pump has drained either one should reach the screen once, showing the
// later state. The host does the same for keys already on screen.
for (PendingNotice& queued : g_notice_queue) {
if (queued.key == notice.key) {
queued = std::move(notice);
return;
}
}
if (g_notice_queue.size() >= kNoticeQueueMax) {
return; // keep the oldest; they are the ones the user has been waiting to see
}
g_notice_queue.push_back(std::move(notice));
}
// Achievement/gameplay chatter, suppressed by the user's "notifications" option. Failures that
// mean achievements are silently not being tracked deliberately go through QueueNotice() instead
// — turning notifications off asks for less noise, not for a broken session to stay hidden.
void QueueAchievementNotice(int kind, std::string key, std::string title,
std::string detail = std::string(),
std::string image_url = std::string()) {
if (!StoreGetBool("Notifications", true)) {
return;
}
QueueNotice(kind, std::move(key), std::move(title), std::move(detail), std::move(image_url));
}
// Leaderboard traffic. Separately switchable and separately timed, because a leaderboard attempt
// fires start/fail pairs during normal play far more often than an achievement unlocks — a user
// who wants unlocks but not that stream has an option for exactly this, and it has never until
// now had anything to suppress.
void QueueLeaderboardNotice(uint32_t leaderboard_id, std::string title,
std::string detail = std::string(),
std::string image_url = std::string()) {
if (!StoreGetBool("Notifications", true) || !StoreGetBool("LeaderboardNotifications", true)) {
return;
}
// Keyed by leaderboard, not by event: start → submitted → scoreboard for one attempt is one
// story, and it should update a single toast rather than stack three.
QueueNotice(ARMSX_ACH_NOTICE_LEADERBOARD, "ra_lb_" + std::to_string(leaderboard_id),
std::move(title), std::move(detail), std::move(image_url),
std::clamp(StoreGetInt("LeaderboardsDuration", 10), 3, 30) * 1000);
}
// Hand parked notices to the host. Must be called with g_lock NOT held.
void DrainNotices() {
armsx_ach_notify_fn handler = nullptr;
void* user = nullptr;
{
std::lock_guard<std::recursive_mutex> lock(g_lock);
handler = g_notify_handler;
user = g_notify_user;
}
PendingNotice notice;
{
std::lock_guard<std::mutex> lock(g_notice_mutex);
if (g_notice_queue.empty()) {
return;
}
if (!handler) {
// No host installed (non-Android, or before EnsureAchievementsReady). Drop them
// rather than let the queue sit full and swallow later ones.
g_notice_queue.clear();
return;
}
const auto now = std::chrono::steady_clock::now();
if (now < g_notice_next_at) {
return; // let the one just released land before stacking another on top of it
}
notice = std::move(g_notice_queue.front());
g_notice_queue.pop_front();
g_notice_next_at = now + std::chrono::milliseconds(kNoticeSpacingMs);
}
handler(notice.kind, notice.key.c_str(), notice.title.c_str(), notice.detail.c_str(),
notice.image_url.c_str(), notice.duration_ms, user);
}
void ClearNotices() {
std::lock_guard<std::mutex> lock(g_notice_mutex);
g_notice_queue.clear();
g_notice_next_at = {};
}
// --- RA image URLs -------------------------------------------------------------------------
//
// The picture that makes a toast a RetroAchievements toast rather than a line of text: the user's
// avatar on sign-in, the game's box art at boot, the achievement's badge on an unlock. rc_client
// composes them into a caller-provided buffer, and every one can legitimately come back empty —
// a user with no avatar, a game with no badge — so failure returns "" and the host draws its
// fallback icon. Nothing here downloads anything; the host owns fetching and caching.
std::string UserImageUrl(const rc_client_t* client) {
const rc_client_user_t* user = client ? rc_client_get_user_info(client) : nullptr;
if (!user) {
return std::string();
}
char url[512];
if (rc_client_user_get_image_url(user, url, sizeof(url)) != RC_OK) {
// Restored-from-token sessions have the URL in the store before the client has a user.
return StoreGet("AvatarUrl");
}
return std::string(url);
}
std::string GameImageUrl(const rc_client_t* client) {
const rc_client_game_t* game = client ? rc_client_get_game_info(client) : nullptr;
if (!game) {
return std::string();
}
char url[512];
if (rc_client_game_get_image_url(game, url, sizeof(url)) != RC_OK) {
return std::string();
}
return std::string(url);
}
std::string AchievementImageUrl(const rc_client_achievement_t* achievement) {
if (!achievement) {
return std::string();
}
// Always the UNLOCKED art. This is only ever called for an achievement the player just
// earned, and the locked badge is the greyed-out version — showing that on an unlock toast
// would read as a failure.
char url[512];
if (rc_client_achievement_get_image_url(achievement, RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED, url,
sizeof(url)) != RC_OK) {
return std::string();
}
return std::string(url);
}
// --- rc_client callbacks -------------------------------------------------------------------
uint32_t RC_CCONV ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes,
rc_client_t* /*client*/) {
if (!g_psx || !buffer || num_bytes == 0) {
return 0;
}
if (static_cast<uint64_t>(address) + num_bytes > kRaExposedSize) {
return 0;
}
// A read may not straddle the RAM/scratchpad boundary — RA's map places them next to each
// other, the hardware does not.
if (address < kRaMainRamSize) {
if (address + num_bytes > kRaMainRamSize) {
return 0;
}
const psx_ram_t* ram = g_psx->ram;
if (!ram || !ram->buf || ram->size < kRaMainRamSize) {
return 0;
}
std::memcpy(buffer, ram->buf + address, num_bytes);
return num_bytes;
}
const psx_scratchpad_t* scratchpad = g_psx->scratchpad;
if (!scratchpad || !scratchpad->buf) {
return 0;
}
std::memcpy(buffer, scratchpad->buf + (address - kRaMainRamSize), num_bytes);
return num_bytes;
}
void RC_CCONV ClientServerCall(const rc_api_request_t* request, rc_client_server_callback_t callback,
void* callback_data, rc_client_t* /*client*/) {
if (!request || !request->url) {
return;
}
HttpEnqueue(request->url,
request->post_data ? request->post_data : "",
request->post_data != nullptr,
request->content_type ? request->content_type : "",
callback, callback_data);
}
void RC_CCONV ClientMessage(const char* message, const rc_client_t* /*client*/) {
ARMSX_ACH_LOGI("%s", message ? message : "");
(void)message;
}
void PlayUnlockSound() {
if (!StoreGetBool("SoundEffects", true)) {
return;
}
const std::string path = StoreGet("UnlockSoundPath");
if (path.empty() || !g_sound_handler) {
return;
}
g_sound_handler(path.c_str(), g_sound_user);
}
void RC_CCONV ClientEventHandler(const rc_client_event_t* event, rc_client_t* client) {
if (!event) {
return;
}
switch (event->type) {
case RC_CLIENT_EVENT_ACHIEVEMENT_TRIGGERED: {
if (event->achievement) {
const rc_client_achievement_t* ach = event->achievement;
ARMSX_ACH_LOGI("unlocked: %s (%u pts)", ach->title ? ach->title : "?", ach->points);
// Title line is the achievement's own name — the badge next to it already says
// "achievement", so spending the first line on the word would push the name into
// the ellipsis on a phone.
std::string title = (ach->title && *ach->title) ? ach->title : "Achievement unlocked";
std::string detail = (ach->description && *ach->description) ? ach->description : "";
if (ach->points > 0) {
if (!detail.empty()) {
detail += " · ";
}
detail += std::to_string(ach->points);
detail += ach->points == 1 ? " point" : " points";
}
// Per-achievement key: two different unlocks must never collapse into one toast.
QueueAchievementNotice(ARMSX_ACH_NOTICE_UNLOCK,
"ra_unlock_" + std::to_string(ach->id), std::move(title),
std::move(detail), AchievementImageUrl(ach));
}
PlayUnlockSound();
break;
}
case RC_CLIENT_EVENT_GAME_COMPLETED: {
ARMSX_ACH_LOGI("game completed");
// "Completed", not "Mastered". On RetroAchievements those are two different
// accomplishments and mastery is the hardcore one — which this build cannot earn.
const rc_client_game_t* game = client ? rc_client_get_game_info(client) : nullptr;
std::string title = "Completed ";
title += (game && game->title && *game->title) ? game->title : "this game";
rc_client_user_game_summary_t summary{};
if (client) {
rc_client_get_user_game_summary(client, &summary);
}
std::string detail;
if (summary.num_core_achievements > 0) {
detail = "All ";
detail += std::to_string(summary.num_core_achievements);
detail += " achievements earned";
if (summary.points_core > 0) {
detail += " · ";
detail += std::to_string(summary.points_core);
detail += " points";
}
} else {
detail = "Every achievement earned";
}
QueueAchievementNotice(ARMSX_ACH_NOTICE_MASTERY, "ra_mastery", std::move(title),
std::move(detail), GameImageUrl(client));
PlayUnlockSound();
break;
}
case RC_CLIENT_EVENT_SUBSET_COMPLETED: {
std::string title = "Completed ";
title += (event->subset && event->subset->title && *event->subset->title)
? event->subset->title
: "this achievement set";
ARMSX_ACH_LOGI("%s", title.c_str());
QueueAchievementNotice(ARMSX_ACH_NOTICE_MASTERY,
"ra_subset_" +
std::to_string(event->subset ? event->subset->id : 0u),
std::move(title), "Every achievement in the set earned",
GameImageUrl(client));
PlayUnlockSound();
break;
}
// --- Leaderboards ----------------------------------------------------------------
//
// rc_client evaluates these whether or not anything is shown, so the only thing that was
// ever missing was somewhere to put them. They carry the game's art rather than a badge:
// leaderboards have no image of their own on RetroAchievements.
case RC_CLIENT_EVENT_LEADERBOARD_STARTED: {
if (!event->leaderboard) {
break;
}
const rc_client_leaderboard_t* lb = event->leaderboard;
ARMSX_ACH_LOGI("leaderboard started: %s", lb->title ? lb->title : "?");
std::string detail = (lb->description && *lb->description) ? lb->description
: "Attempt started";
QueueLeaderboardNotice(lb->id,
(lb->title && *lb->title) ? lb->title : "Leaderboard attempt",
std::move(detail), GameImageUrl(client));
break;
}
case RC_CLIENT_EVENT_LEADERBOARD_FAILED: {
if (!event->leaderboard) {
break;
}
const rc_client_leaderboard_t* lb = event->leaderboard;
ARMSX_ACH_LOGI("leaderboard failed: %s", lb->title ? lb->title : "?");
std::string title = "Attempt failed: ";
title += (lb->title && *lb->title) ? lb->title : "leaderboard";
QueueLeaderboardNotice(lb->id, std::move(title), "No score was submitted",
GameImageUrl(client));
break;
}
case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED: {
if (!event->leaderboard) {
break;
}
const rc_client_leaderboard_t* lb = event->leaderboard;
ARMSX_ACH_LOGI("leaderboard submitted: %s (%s)", lb->title ? lb->title : "?",
lb->tracker_value ? lb->tracker_value : "");
std::string detail = "Submitted";
if (lb->tracker_value && *lb->tracker_value) {
detail += " ";
detail += lb->tracker_value;
}
QueueLeaderboardNotice(lb->id, (lb->title && *lb->title) ? lb->title : "Leaderboard",
std::move(detail), GameImageUrl(client));
break;
}
case RC_CLIENT_EVENT_LEADERBOARD_SCOREBOARD: {
// The server's answer to a submission: where the score actually landed. Worth its own
// toast because RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED fires optimistically, before the
// rank is known.
if (!event->leaderboard_scoreboard) {
break;
}
const rc_client_leaderboard_scoreboard_t* sb = event->leaderboard_scoreboard;
const rc_client_leaderboard_t* lb =
client ? rc_client_get_leaderboard_info(client, sb->leaderboard_id) : nullptr;
std::string title = (lb && lb->title && *lb->title) ? lb->title : "Leaderboard";
std::string detail;
if (sb->submitted_score[0]) {
detail += "Scored ";
detail += sb->submitted_score;
}
if (sb->new_rank > 0) {
if (!detail.empty()) {
detail += " · ";
}
detail += "rank ";
detail += std::to_string(sb->new_rank);
if (sb->num_entries > 0) {
detail += " of ";
detail += std::to_string(sb->num_entries);
}
}
// The player's own best, when the new score did not beat it — otherwise the toast
// reads as though a worse run replaced a better one.
if (sb->best_score[0] && sb->submitted_score[0] &&
std::strcmp(sb->best_score, sb->submitted_score) != 0) {
detail += " · best ";
detail += sb->best_score;
}
if (detail.empty()) {
detail = "Score submitted";
}
ARMSX_ACH_LOGI("leaderboard scoreboard: %s", detail.c_str());
QueueLeaderboardNotice(sb->leaderboard_id, std::move(title), std::move(detail),
GameImageUrl(client));
break;
}
case RC_CLIENT_EVENT_SERVER_ERROR: {
const char* message = event->server_error && event->server_error->error_message
? event->server_error->error_message
: nullptr;
ARMSX_ACH_LOGE("server error: %s", message ? message : "(unknown)");
// Ungated: this is a request rcheevos has given up on, so something the user did
// (most often an unlock) did not reach their account.
QueueNotice(ARMSX_ACH_NOTICE_ERROR, "ra_server_error", "RetroAchievements error",
(message && *message) ? message : "A request could not be completed");
break;
}
case RC_CLIENT_EVENT_DISCONNECTED:
ARMSX_ACH_LOGW("disconnected from RetroAchievements");
// Shares a key with the reconnect notice: they are two states of one thing, and a
// flaky connection should toggle a single toast rather than build a column of them.
QueueAchievementNotice(ARMSX_ACH_NOTICE_INFO, "ra_connection",
"RetroAchievements is offline",
"Unlocks will be sent when the connection returns");
break;
case RC_CLIENT_EVENT_RECONNECTED:
ARMSX_ACH_LOGI("reconnected to RetroAchievements");
QueueAchievementNotice(ARMSX_ACH_NOTICE_INFO, "ra_connection",
"RetroAchievements reconnected", "Pending unlocks sent");
break;
default:
// Deliberately not handled: the challenge and progress indicators
// (RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_* /
// RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_*) and the leaderboard tracker
// (RC_CLIENT_EVENT_LEADERBOARD_TRACKER_*) are persistent on-screen widgets driven by
// show/hide/update triples, not transient notes — routing them through the toast
// stack would spawn a toast every time a challenge armed or a timer ticked. They need
// a pinned HUD element, which is a separate piece of work.
// RC_CLIENT_EVENT_RESET only fires when hardcore is switched on, which this module
// cannot do.
break;
}
}
// --- Client lifecycle ----------------------------------------------------------------------
// Push every persisted option into the client. Hardcore is set here, once, to 0 — this is the
// ONLY rc_client_set_hardcore_enabled call in the module and its argument is a literal.
void ApplyClientOptions() {
if (!g_client) {
return;
}
rc_client_set_hardcore_enabled(g_client, 0);
rc_client_set_encore_mode_enabled(g_client, StoreGetBool("EncoreMode", false) ? 1 : 0);
rc_client_set_spectator_mode_enabled(g_client, StoreGetBool("SpectatorMode", false) ? 1 : 0);
rc_client_set_unofficial_enabled(g_client, StoreGetBool("UnofficialTestMode", false) ? 1 : 0);
}
bool CreateClient() {
if (g_client) {
return true;
}
g_client = rc_client_create(ClientReadMemory, ClientServerCall);
if (!g_client) {
ARMSX_ACH_LOGE("rc_client_create() failed");
return false;
}
rc_client_enable_logging(g_client, RC_CLIENT_LOG_LEVEL_WARN, ClientMessage);
rc_client_set_event_handler(g_client, ClientEventHandler);
ApplyClientOptions();
const std::string host = StoreGet("Host");
if (!host.empty()) {
ARMSX_ACH_LOGI("using host override %s", host.c_str());
rc_client_set_host(g_client, host.c_str());
}
return true;
}
void RC_CCONV LoadGameCallback(int result, const char* error_message, rc_client_t* client,
void* /*userdata*/) {
g_game_load_in_flight = false;
if (result != RC_OK) {
g_has_achievements = false;
const char* detail = error_message && *error_message ? error_message : rc_error_str(result);
if (result == RC_NO_GAME_LOADED) {
// An unrecognised disc. NOTHING is shown here, on purpose, and this is the same
// choice ARMSX2 makes (Achievements::ClientLoadGameCallback writes a console line for
// RC_NO_GAME_LOADED and returns). Most PS1 discs a user actually owns are not in the
// RA database — homebrew, demos, unlisted regional pressings — so a banner on every
// one of those boots would be a recurring notice about something the user cannot act
// on, and it would be indistinguishable from a real failure. The log line stays.
ARMSX_ACH_LOGI("no achievement set for this disc: %s", detail);
} else if (result == RC_LOGIN_REQUIRED) {
// The sign-in re-arms the load; there is nothing to report yet, and TokenLoginCallback
// speaks up if that sign-in is the thing that failed.
ARMSX_ACH_LOGI("disc identification deferred until sign-in completes");
} else {
// A real failure — network, server, a malformed response. Worth saying: without it
// the achievements panel is simply empty for a game that does have a set, which
// reads exactly like "achievements are broken".
ARMSX_ACH_LOGW("could not load achievements: %s", detail);
QueueNotice(ARMSX_ACH_NOTICE_ERROR, "ra_game_load",
"Could not load this game's achievements",
(detail && *detail) ? detail : "The set could not be fetched");
}
return;
}
g_has_achievements = rc_client_has_achievements(client) != 0;
const rc_client_game_t* game = rc_client_get_game_info(client);
ARMSX_ACH_LOGI("loaded '%s' (id %u), achievements=%s",
game && game->title ? game->title : "?",
game ? game->id : 0u,
g_has_achievements ? "yes" : "no");
// The boot summary, the thing a RetroAchievements user expects to see the moment a game
// starts. Wording follows ARMSX2's DisplayAchievementSummary(): the title, then how much of
// the set is already done, or "no achievements" for a game that is in the database with an
// empty set.
rc_client_user_game_summary_t summary{};
rc_client_get_user_game_summary(client, &summary);
std::string title = (game && game->title && *game->title) ? game->title : "This game";
// Only ever appended when hardcore is genuinely on. It is wired shut in this module, so
// asking is how the text stays honest instead of hard-coding an assumption in a second place.
if (armsx_ach_hardcore_active()) {
title += " (Hardcore Mode)";
}
std::string detail;
if (summary.num_core_achievements > 0) {
detail = std::to_string(summary.num_unlocked_achievements);
detail += " of ";
detail += std::to_string(summary.num_core_achievements);
detail += " achievements · ";
detail += std::to_string(summary.points_unlocked);
detail += " of ";
detail += std::to_string(summary.points_core);
detail += " points";
} else {
detail = "This game has no achievements";
}
QueueAchievementNotice(ARMSX_ACH_NOTICE_GAME, "ra_game_summary", std::move(title),
std::move(detail), GameImageUrl(client));
}
// The "signed in as …" toast, with the avatar and the account's score. Shared by the saved-token
// restore and the interactive password login so both routes look identical to the user.
//
// This used to be deliberately suppressed: the host had ONE banner slot, so a sign-in note posted
// here was overwritten by the game summary a frame or two later and never got read. The host now
// stacks toasts and collapses by key, so the two coexist — which is the whole reason the summary
// alone was never the "logged in" confirmation a RetroAchievements user is looking for.
void QueueLoginNotice(const rc_client_t* client) {
const rc_client_user_t* user = client ? rc_client_get_user_info(client) : nullptr;
if (!user) {
return;
}
std::string title = "Signed in as ";
if (user->display_name && *user->display_name) {
title += user->display_name;
} else if (user->username && *user->username) {
title += user->username;
} else {
title += "RetroAchievements";
}
// Softcore is the only score this build can move, so it leads. The full score is still worth
// showing — it is the number on the user's profile page — but labelling which is which is
// what stops "my points are wrong" reports from a softcore-only client.
std::string detail = std::to_string(user->score_softcore);
detail += " softcore points";
if (user->score > 0) {
detail += " · ";
detail += std::to_string(user->score);
detail += " total";
}
if (user->num_unread_messages > 0) {
detail += " · ";
detail += std::to_string(user->num_unread_messages);
detail += user->num_unread_messages == 1 ? " unread message" : " unread messages";
}
QueueAchievementNotice(ARMSX_ACH_NOTICE_LOGIN, "ra_login", std::move(title), std::move(detail),
UserImageUrl(client));
}
void RC_CCONV TokenLoginCallback(int result, const char* error_message, rc_client_t* client,
void* /*userdata*/) {
if (result != RC_OK) {
ARMSX_ACH_LOGW("saved login rejected: %s", error_message ? error_message : rc_error_str(result));
// A stale token would otherwise be retried on every boot forever.
StoreErase("Token");
// Ungated by the "notifications" option: the account is signed out from here on and every
// achievement earned this session goes nowhere. Silently erasing the token and carrying on
// is exactly the failure the user cannot diagnose — the panel just shows the login form
// again with no explanation of when or why.
QueueNotice(ARMSX_ACH_NOTICE_ERROR, "ra_login", "RetroAchievements sign-in expired",
"Sign in again from the Achievements screen");
return;
}
ARMSX_ACH_LOGI("signed in from saved token");
// The toast the user has been missing at every boot. It arrives before the game summary and
// sits above it in the stack, so a normal start now reads: signed in as <you>, then <game> —
// N of M achievements. Refresh the stored score/avatar off the response while it is here, so
// the library screen agrees with what the toast just said.
const rc_client_user_t* user = client ? rc_client_get_user_info(client) : nullptr;
if (user) {
StoreSetInt("LastScore", static_cast<int>(user->score));
StoreSetInt("LastScoreSoftcore", static_cast<int>(user->score_softcore));
char url[512];
if (rc_client_user_get_image_url(user, url, sizeof(url)) == RC_OK) {
StoreSet("AvatarUrl", url);
}
}
QueueLoginNotice(client);
if (g_psx) {
g_pending_game_load = true;
}
}
struct PasswordLoginState {
bool done = false;
bool ok = false;
std::string error;
};
void RC_CCONV PasswordLoginCallback(int result, const char* error_message, rc_client_t* client,
void* userdata) {
PasswordLoginState* state = static_cast<PasswordLoginState*>(userdata);
if (!state) {
return;
}
state->done = true;
if (result != RC_OK) {
state->ok = false;
state->error = error_message && *error_message ? error_message : rc_error_str(result);
return;
}
const rc_client_user_t* user = rc_client_get_user_info(client);
if (!user || !user->token) {
state->ok = false;
state->error = "RetroAchievements did not return a login token.";
return;
}
state->ok = true;
StoreSet("Username", user->username ? user->username : "");
StoreSet("DisplayName", user->display_name ? user->display_name : (user->username ? user->username : ""));
StoreSet("Token", user->token);
StoreSetInt("LastScore", static_cast<int>(user->score));
StoreSetInt("LastScoreSoftcore", static_cast<int>(user->score_softcore));
char url[512];
if (rc_client_user_get_image_url(user, url, sizeof(url)) == RC_OK) {
StoreSet("AvatarUrl", url);
}
// Same toast as the saved-token route. The Achievements screen the user is standing in front
// of already redraws itself on success, but the toast is what confirms it while a game is
// running behind the pause menu.
QueueLoginNotice(client);
}
// Kick a game load for whatever disc is mounted. Caller holds g_lock and must be on the
// emulation thread — this reads the disc image.
void BeginLoadGame() {
g_pending_game_load = false;
if (!g_client || !g_psx) {
return;
}
if (!rc_client_get_user_info(g_client)) {
return; // not signed in yet; the token/password login re-arms this
}
psx_cdrom_t* cdrom = psx_get_cdrom(g_psx);
const char* disc_path = cdrom ? psx_cdrom_get_disc_path(cdrom) : nullptr;
if (!disc_path || !*disc_path) {
return; // BIOS or .exe boot — nothing to identify
}
const std::string hash = HashDiscImage(disc_path);
if (hash.empty()) {
return;
}
if (hash == g_game_hash && (g_has_achievements || g_game_load_in_flight)) {
return; // same disc, already loaded
}
g_game_hash = hash;
g_has_achievements = false;
g_game_load_in_flight = true;
ARMSX_ACH_LOGI("identifying disc %s (hash %s)", disc_path, hash.c_str());
rc_client_begin_load_game(g_client, hash.c_str(), LoadGameCallback, nullptr);
}
// --- JSON ----------------------------------------------------------------------------------
void AppendJsonString(std::string& dst, const char* src) {
dst += '"';
if (src) {
for (const char* p = src; *p; ++p) {
const unsigned char c = static_cast<unsigned char>(*p);
switch (c) {
case '"': dst += "\\\""; break;
case '\\': dst += "\\\\"; break;
case '\n': dst += "\\n"; break;
case '\r': dst += "\\r"; break;
case '\t': dst += "\\t"; break;
default:
if (c < 0x20) {
char escape[8];
std::snprintf(escape, sizeof(escape), "\\u%04x", c);
dst += escape;
} else {
dst += static_cast<char>(c);
}
break;
}
}
}
dst += '"';
}
void AppendJsonBool(std::string& dst, const char* key, bool value) {
dst += ",\"";
dst += key;
dst += "\":";
dst += value ? "true" : "false";
}
void AppendJsonInt(std::string& dst, const char* key, long long value) {
dst += ",\"";
dst += key;
dst += "\":";
dst += std::to_string(value);
}
} // namespace
// ------------------------------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------------------------------
void armsx_ach_set_http_handler(armsx_ach_http_fn handler, void* user) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
g_http_handler = handler;
g_http_user = user;
}
void armsx_ach_set_sound_handler(armsx_ach_sound_fn handler, void* user) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
g_sound_handler = handler;
g_sound_user = user;
}
void armsx_ach_set_notify_handler(armsx_ach_notify_fn handler, void* user) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
g_notify_handler = handler;
g_notify_user = user;
}
const char* armsx_ach_user_agent(void) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (g_user_agent.empty()) {
g_user_agent = ARMSX1_RA_UA_NAME "/" ARMSX1_RA_UA_VERSION
#if defined(__ANDROID__)
" (Android)";
#else
" (ARMSX)";
#endif
}
return g_user_agent.c_str();
}
void armsx_ach_startup(void) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (g_startup_done) {
return;
}
g_shutting_down = false;
if (!CreateClient()) {
return;
}
// The saved login lives next to settings.toml, so there is nothing to restore until the host
// has handed a pref path in. Leave startup un-latched in that case and let the next call
// (the Android host makes one from every RetroAchievements JNI entry point) finish the job.
if (StorePath().empty()) {
return;
}
g_startup_done = true;
if (!rc_client_get_user_info(g_client) &&
!StoreGet("Username").empty() && !StoreGet("Token").empty()) {
g_pending_token_login = true;
}
ARMSX_ACH_LOGI("startup, user agent '%s'", armsx_ach_user_agent());
}
void armsx_ach_shutdown(void) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (!g_startup_done) {
return;
}
g_shutting_down = true;
g_psx = nullptr;
// Let anything already on the wire land so an unlock submitted on the last frame is not
// dropped, then tear the client down.
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (HttpBusy() && std::chrono::steady_clock::now() < deadline) {
HttpPoll();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
HttpPoll();
if (g_client) {
rc_client_destroy(g_client);
g_client = nullptr;
}
// Anything still on the wire now belongs to a client that no longer exists. Retire the
// generation so a late worker's response is dropped instead of being handed to whatever
// client comes next.
{
std::lock_guard<std::mutex> lock(g_http_mutex);
++g_http_generation;
g_http_done.clear();
}
g_startup_done = false;
g_has_achievements = false;
g_game_hash.clear();
g_rich_presence.clear();
ClearNotices();
}
void armsx_ach_session_started(psx_t* psx) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
g_psx = psx;
g_game_hash.clear();
g_has_achievements = false;
g_rich_presence.clear();
g_pending_game_load = psx != nullptr;
// A note parked while no session was running (the RA screen is reachable from the library, so
// the pump that drains them may not have run for a long time) must not surface over the boot
// of an unrelated game. This runs before any of this session's own notices are queued.
ClearNotices();
}
void armsx_ach_session_ended(void) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
g_psx = nullptr;
g_pending_game_load = false;
g_game_load_in_flight = false;
g_has_achievements = false;
g_game_hash.clear();
g_rich_presence.clear();
// Anything still parked belongs to the game that just closed — an unlock banner arriving over
// the library after the fact would be about a session that no longer exists.
ClearNotices();
if (g_client) {
rc_client_unload_game(g_client);
}
}
void armsx_ach_frame_update(bool stepping) {
// Every rc_client callback that has something to tell the user parks it instead of saying it
// (see the notice queue), so this pump is where it actually reaches the screen. Scoped so the
// drain below runs with g_lock released: the host handler makes a JNI call, and no part of
// this module should be able to stall behind it.
{
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (!g_client || g_shutting_down) {
return;
}
HttpPoll();
if (g_pending_token_login) {
g_pending_token_login = false;
const std::string user = StoreGet("Username");
const std::string token = StoreGet("Token");
if (!user.empty() && !token.empty()) {
rc_client_begin_login_with_token(g_client, user.c_str(), token.c_str(),
TokenLoginCallback, nullptr);
}
}
if (g_pending_game_load && !g_game_load_in_flight) {
BeginLoadGame();
}
if (stepping && g_has_achievements) {
rc_client_do_frame(g_client);
} else {
rc_client_idle(g_client);
}
char buffer[512];
const size_t length = rc_client_get_rich_presence_message(g_client, buffer, sizeof(buffer));
g_rich_presence.assign(buffer, length);
}
DrainNotices();
}
std::string armsx_ach_get_json(void) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
std::string out;
out.reserve(4096);
out += '{';
const rc_client_user_t* user = g_client ? rc_client_get_user_info(g_client) : nullptr;
// The library's RA tab is opened with no VM at all — and on Android there is no emulation
// loop then, so no client has ever run. Fall back to what the last successful login wrote so
// the panel still shows the signed-in account instead of the login form.
std::string display_name;
bool logged_in = false;
if (user && user->display_name) {
display_name = user->display_name;
logged_in = true;
} else {
std::string saved_user = StoreGet("DisplayName");
if (saved_user.empty()) {
saved_user = StoreGet("Username");
}
if (!saved_user.empty() && !StoreGet("Token").empty()) {
display_name = saved_user;
logged_in = true;
}
}
out += "\"active\":";
out += (g_has_achievements ? "true" : "false");
AppendJsonBool(out, "loggedIn", logged_in);
// Softcore only. This is reported false unconditionally, not read back from anywhere, so no
// future settings edit can flip it by accident.
AppendJsonBool(out, "hardcore", false);
out += ",\"userName\":";
AppendJsonString(out, display_name.c_str());
AppendJsonInt(out, "score", user ? static_cast<long long>(user->score)
: StoreGetInt("LastScore", -1));
AppendJsonInt(out, "softcoreScore", user ? static_cast<long long>(user->score_softcore)
: StoreGetInt("LastScoreSoftcore", -1));
std::string avatar_url;
if (user) {
char url[512];
if (rc_client_user_get_image_url(user, url, sizeof(url)) == RC_OK) {
avatar_url = url;
}
}
if (avatar_url.empty()) {
avatar_url = StoreGet("AvatarUrl");
}
out += ",\"avatarUrl\":";
AppendJsonString(out, avatar_url.c_str());
AppendJsonBool(out, "notifications", StoreGetBool("Notifications", true));
AppendJsonBool(out, "leaderboardNotifications", StoreGetBool("LeaderboardNotifications", true));
AppendJsonBool(out, "overlays", StoreGetBool("Overlays", true));
AppendJsonBool(out, "lbOverlays", StoreGetBool("LBOverlays", true));
AppendJsonBool(out, "soundEffects", StoreGetBool("SoundEffects", true));
AppendJsonBool(out, "encoreMode", StoreGetBool("EncoreMode", false));
AppendJsonBool(out, "spectatorMode", StoreGetBool("SpectatorMode", false));
AppendJsonBool(out, "unofficialTestMode", StoreGetBool("UnofficialTestMode", false));
AppendJsonInt(out, "notificationsDuration", StoreGetInt("NotificationsDuration", 5));
AppendJsonInt(out, "leaderboardsDuration", StoreGetInt("LeaderboardsDuration", 10));
AppendJsonInt(out, "notificationPosition", StoreGetInt("NotificationPosition", 1));
AppendJsonInt(out, "overlayPosition", StoreGetInt("OverlayPosition", 8));
out += ",\"items\":[";
if (g_client && g_has_achievements) {
// LOCK_STATE grouping so every bucket carries the real subset_id (PROGRESS grouping
// misfiles in-progress achievements into subset 0).
rc_client_achievement_list_t* list = rc_client_create_achievement_list(
g_client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE);
if (list) {
static const uint32_t bucket_order[] = {
RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE,
RC_CLIENT_ACHIEVEMENT_BUCKET_RECENTLY_UNLOCKED,
RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED,
RC_CLIENT_ACHIEVEMENT_BUCKET_ALMOST_THERE,
RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED,
RC_CLIENT_ACHIEVEMENT_BUCKET_UNOFFICIAL,
RC_CLIENT_ACHIEVEMENT_BUCKET_UNSUPPORTED,
};
bool first = true;
for (uint32_t wanted : bucket_order) {
for (uint32_t b = 0; b < list->num_buckets; ++b) {
const rc_client_achievement_bucket_t& bucket = list->buckets[b];
if (bucket.bucket_type != wanted) {
continue;
}
for (uint32_t a = 0; a < bucket.num_achievements; ++a) {
const rc_client_achievement_t* achievement = bucket.achievements[a];
if (!achievement) {
continue;
}
if (!first) {
out += ',';
}
first = false;
out += "{\"id\":";
out += std::to_string(achievement->id);
out += ",\"title\":";
AppendJsonString(out, achievement->title);
out += ",\"description\":";
AppendJsonString(out, achievement->description);
AppendJsonInt(out, "points", achievement->points);
AppendJsonBool(out, "unlocked",
achievement->state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
// rc_client's SOFTCORE/HARDCORE bitmask. Softcore-only means bit 1 is the
// only one that can ever be set here, which is exactly what the Kotlin
// side wants to see.
AppendJsonInt(out, "unlockedMask", static_cast<int>(achievement->unlocked));
AppendJsonBool(out, "primed",
achievement->bucket == RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE);
AppendJsonInt(out, "bucket", static_cast<int>(bucket.bucket_type));
AppendJsonInt(out, "subsetId", bucket.subset_id);
AppendJsonInt(out, "type", static_cast<int>(achievement->type));
AppendJsonInt(out, "unlockTime", static_cast<long long>(achievement->unlock_time));
out += ",\"measuredProgress\":";
AppendJsonString(out, achievement->measured_progress);
{
char buffer[32];
std::snprintf(buffer, sizeof(buffer), "%.1f", achievement->measured_percent);
out += ",\"measuredPercent\":";
out += buffer;
std::snprintf(buffer, sizeof(buffer), "%.1f", achievement->rarity);
out += ",\"rarity\":";
out += buffer;
}
{
char url[256];
const int rc = rc_client_achievement_get_image_url(
achievement, achievement->state, url, sizeof(url));
out += ",\"iconUrl\":";
AppendJsonString(out, rc == RC_OK ? url : "");
}
out += '}';
}
}
}
rc_client_destroy_achievement_list(list);
}
}
out += ']';
out += ",\"subsets\":[";
if (g_client && g_has_achievements) {
rc_client_subset_list_t* subsets = rc_client_create_subset_list(g_client);
if (subsets) {
bool first = true;
for (uint32_t s = 0; s < subsets->num_subsets; ++s) {
const rc_client_subset_t* subset = subsets->subsets[s];
if (!subset) {
continue;
}
if (!first) {
out += ',';
}
first = false;
out += "{\"id\":";
out += std::to_string(subset->id);
out += ",\"title\":";
AppendJsonString(out, subset->title);
AppendJsonInt(out, "numAchievements", subset->num_achievements);
out += '}';
}
rc_client_destroy_subset_list(subsets);
}
}
out += "]}";
return out;
}
std::string armsx_ach_get_rich_presence(void) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
return g_rich_presence;
}
std::string armsx_ach_login(const char* username, const char* password) {
if (!username || !*username || !password || !*password) {
return "Enter your RetroAchievements username and password.";
}
PasswordLoginState state;
{
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (!CreateClient()) {
return "Could not create the RetroAchievements client.";
}
g_shutting_down = false;
if (!rc_client_begin_login_with_password(g_client, username, password,
PasswordLoginCallback, &state)) {
return "Could not start the login request.";
}
}
// Pump the request here rather than waiting for the emulation loop: this call has to work
// from the library, where no VM (and therefore no loop) exists. The lock is taken in short
// bursts so a running game keeps its frame pace.
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kLoginTimeoutMs);
for (;;) {
{
std::lock_guard<std::recursive_mutex> lock(g_lock);
HttpPoll();
if (state.done) {
break;
}
rc_client_idle(g_client);
}
if (std::chrono::steady_clock::now() >= deadline) {
return "Timed out talking to RetroAchievements.";
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
{
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (!state.ok) {
// The screen shows this error itself; drop the sign-in toast the failed attempt may
// have parked so it cannot surface later over an unrelated game.
ClearNotices();
return state.error.empty() ? std::string("Login failed.") : state.error;
}
ApplyClientOptions();
if (g_psx) {
g_pending_game_load = true; // the emulation thread picks this up and hashes the disc
}
}
// Deliver the "signed in as …" toast now, with g_lock released. Signing in from the library
// means there is no emulation loop to run the pump, so without this the toast would sit in the
// queue until a game started — and then appear as a stale sign-in note over the boot summary.
DrainNotices();
return {};
}
void armsx_ach_logout(void) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (g_client) {
rc_client_logout(g_client);
}
StoreErase("Token");
StoreErase("Username");
StoreErase("DisplayName");
StoreErase("AvatarUrl");
StoreErase("LastScore");
StoreErase("LastScoreSoftcore");
g_has_achievements = false;
g_game_hash.clear();
g_rich_presence.clear();
g_pending_token_login = false;
}
void armsx_ach_set_option(const char* key, bool enabled) {
if (!key || !*key) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_lock);
const std::string name(key);
if (name == "notifications") {
StoreSetBool("Notifications", enabled);
} else if (name == "leaderboardNotifications") {
StoreSetBool("LeaderboardNotifications", enabled);
} else if (name == "overlays") {
StoreSetBool("Overlays", enabled);
} else if (name == "lbOverlays") {
StoreSetBool("LBOverlays", enabled);
} else if (name == "soundEffects") {
StoreSetBool("SoundEffects", enabled);
} else if (name == "encoreMode") {
StoreSetBool("EncoreMode", enabled);
if (g_client) {
rc_client_set_encore_mode_enabled(g_client, enabled ? 1 : 0);
}
} else if (name == "spectatorMode") {
StoreSetBool("SpectatorMode", enabled);
if (g_client) {
rc_client_set_spectator_mode_enabled(g_client, enabled ? 1 : 0);
}
} else if (name == "unofficialTestMode") {
StoreSetBool("UnofficialTestMode", enabled);
if (g_client) {
rc_client_set_unofficial_enabled(g_client, enabled ? 1 : 0);
}
}
// "hardcore" is deliberately not a key here. Nothing the UI can send turns it on.
}
void armsx_ach_set_option_int(const char* key, int value) {
if (!key || !*key) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_lock);
const std::string name(key);
if (name == "notificationsDuration") {
StoreSetInt("NotificationsDuration", std::clamp(value, 3, 30));
} else if (name == "leaderboardsDuration") {
StoreSetInt("LeaderboardsDuration", std::clamp(value, 3, 30));
} else if (name == "notificationPosition") {
StoreSetInt("NotificationPosition", std::clamp(value, 0, 9));
} else if (name == "overlayPosition") {
StoreSetInt("OverlayPosition", std::clamp(value, 0, 8));
}
}
void armsx_ach_set_unlock_sound(const char* path) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (path && *path) {
StoreSet("UnlockSoundPath", path);
} else {
StoreErase("UnlockSoundPath");
}
}
void armsx_ach_set_host_override(const char* host) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
if (!host || !*host) {
armsx_ach_clear_host_override();
return;
}
StoreSet("Host", host);
if (g_client) {
rc_client_set_host(g_client, host);
}
ARMSX_ACH_LOGI("host override -> %s", host);
}
void armsx_ach_clear_host_override(void) {
std::lock_guard<std::recursive_mutex> lock(g_lock);
StoreErase("Host");
if (g_client) {
// "" (not null — rc_client_set_host dereferences the argument) puts it back on
// retroachievements.org.
rc_client_set_host(g_client, "");
}
ARMSX_ACH_LOGI("host override cleared");
}
std::string armsx_ach_hash_for_path(const char* image_path) {
return HashDiscImage(image_path);
}
bool armsx_ach_hardcore_active(void) {
// Softcore only, by construction. Not a stored value and not derived from the client, so
// there is nothing here that a later change could accidentally make true.
return false;
}