From cce5c7f3c5ec9a82cecf2b2f7c8bbe4a296da7ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:55:21 +0000 Subject: [PATCH 01/37] Initial plan From 517e229cc536243f6d6d2bf765f5e04ebb044c62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:11:16 +0000 Subject: [PATCH 02/37] Add game_state module and WebSocket game broadcasting support Co-authored-by: kzryzstof <38137839+kzryzstof@users.noreply.github.com> Agent-Logs-Url: https://github.com/Octelys/retro-arch/sessions/4d5348fa-3720-4052-8411-78781fe81983 --- Makefile.common | 1 + griffin/griffin.c | 1 + network/game_state.c | 211 +++++++++++++++++++++++++++++++++++++++++++ network/game_state.h | 147 ++++++++++++++++++++++++++++++ network/ws_server.c | 115 +++++++++++++++++------ network/ws_server.h | 13 +++ retroarch.c | 9 ++ tasks/task_content.c | 75 +++++++++++++++ 8 files changed, 545 insertions(+), 27 deletions(-) create mode 100644 network/game_state.c create mode 100644 network/game_state.h diff --git a/Makefile.common b/Makefile.common index f201bc81dd20..d53e0867a8c6 100644 --- a/Makefile.common +++ b/Makefile.common @@ -2967,6 +2967,7 @@ endif ifeq ($(HAVE_WEBSOCKET_SERVER), 1) DEFINES += -DHAVE_WEBSOCKET_SERVER OBJ += network/ws_server.o + OBJ += network/game_state.o ifneq ($(findstring Win32,$(OS)),) # Windows x64 / ARM64 – headers and import-lib live under diff --git a/griffin/griffin.c b/griffin/griffin.c index 92dd6843bb27..ebd9ebcd6e14 100644 --- a/griffin/griffin.c +++ b/griffin/griffin.c @@ -1709,6 +1709,7 @@ STEAM INTEGRATION USING MIST #endif #if defined(HAVE_WEBSOCKET_SERVER) && !defined(_MSC_VER) +#include "../network/game_state.c" #include "../network/ws_server.c" #endif diff --git a/network/game_state.c b/network/game_state.c new file mode 100644 index 000000000000..62bb43e08d81 --- /dev/null +++ b/network/game_state.c @@ -0,0 +1,211 @@ +/* RetroArch - A frontend for libretro. + * Copyright (C) 2024 - libretro team + * + * RetroArch is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * RetroArch is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with RetroArch. If not, see . + */ + +/* + * game_state.c – Thread-safe in-memory store for the currently active game. + * + * The module holds a single ra_game_state_t record plus a boolean flag + * that indicates whether a game is running. All public functions + * serialise access through a mutex so they may be called from any thread. + */ + +#include "game_state.h" + +#include +#include +#include + +#include +#include + +/* ------------------------------------------------------------------------- + * Internal state + * ---------------------------------------------------------------------- */ + +static slock_t *g_state_lock = NULL; +static ra_game_state_t g_state; +static bool g_is_running = false; + +/* ------------------------------------------------------------------------- + * Lifecycle + * ---------------------------------------------------------------------- */ + +void game_state_init(void) +{ + if (g_state_lock) + return; /* already initialised */ + g_state_lock = slock_new(); + memset(&g_state, 0, sizeof(g_state)); + g_is_running = false; +} + +void game_state_deinit(void) +{ + if (!g_state_lock) + return; + slock_free(g_state_lock); + g_state_lock = NULL; + g_is_running = false; +} + +/* ------------------------------------------------------------------------- + * Helpers + * ---------------------------------------------------------------------- */ + +/** + * json_append_field: + * @buf : destination buffer. + * @pos : current write offset; updated on return. + * @buf_size : total size of @buf. + * @key : JSON key (must not contain characters needing escaping). + * @value : value string to escape and append. + * + * Appends ,"key":"escaped-value" to @buf starting at *pos. + * Updates *pos. Silently truncates if the buffer is too small. + */ +static void json_append_field(char *buf, size_t *pos, + size_t buf_size, const char *key, const char *value) +{ + /* Write the key prefix */ + int n = snprintf(buf + *pos, buf_size - *pos, ",\"%s\":\"", key); + if (n > 0) + *pos += (size_t)n; + + /* Escape and copy the value character-by-character */ + if (!string_is_empty(value)) + { + const unsigned char *src = (const unsigned char *)value; + while (*src && *pos + 3 < buf_size) /* +3: escape + char + closing " */ + { + unsigned char c = *src++; + if (c == '"' || c == '\\') + { + buf[(*pos)++] = '\\'; + buf[(*pos)++] = (char)c; + } + else if (c >= 0x20) /* skip bare control characters */ + buf[(*pos)++] = (char)c; + } + } + + /* Close the quoted value */ + if (*pos + 1 < buf_size) + buf[(*pos)++] = '"'; +} + +/* ------------------------------------------------------------------------- + * Public API + * ---------------------------------------------------------------------- */ + +void game_state_set(const ra_game_state_t *state) +{ + if (!state || !g_state_lock) + return; + slock_lock(g_state_lock); + g_state = *state; + g_is_running = true; + slock_unlock(g_state_lock); +} + +void game_state_clear(void) +{ + if (!g_state_lock) + return; + slock_lock(g_state_lock); + memset(&g_state, 0, sizeof(g_state)); + g_is_running = false; + slock_unlock(g_state_lock); +} + +bool game_state_is_running(void) +{ + bool running; + if (!g_state_lock) + return false; + slock_lock(g_state_lock); + running = g_is_running; + slock_unlock(g_state_lock); + return running; +} + +bool game_state_get(ra_game_state_t *out) +{ + bool running; + if (!out || !g_state_lock) + return false; + slock_lock(g_state_lock); + running = g_is_running; + if (running) + *out = g_state; + slock_unlock(g_state_lock); + return running; +} + +size_t game_state_to_json(char *buf, size_t buf_size) +{ + ra_game_state_t snap; + bool running; + size_t pos = 0; + int n; + + if (!buf || buf_size < 2) + return 0; + + memset(&snap, 0, sizeof(snap)); + + if (g_state_lock) + { + slock_lock(g_state_lock); + running = g_is_running; + if (running) + snap = g_state; + slock_unlock(g_state_lock); + } + else + { + running = false; + } + + if (!running) + { + n = snprintf(buf, buf_size, "{\"type\":\"no_game\"}"); + return (n > 0) ? (size_t)n : 0; + } + + /* Open object and write the type field */ + n = snprintf(buf, buf_size, "{\"type\":\"game_playing\""); + if (n <= 0) + return 0; + pos = (size_t)n; + + json_append_field(buf, &pos, buf_size, "game_id", snap.game_id); + json_append_field(buf, &pos, buf_size, "game_name", snap.game_name); + json_append_field(buf, &pos, buf_size, "game_path", snap.game_path); + json_append_field(buf, &pos, buf_size, "console_id", snap.console_id); + json_append_field(buf, &pos, buf_size, "console_name", snap.console_name); + json_append_field(buf, &pos, buf_size, "core_name", snap.core_name); + json_append_field(buf, &pos, buf_size, "db_name", snap.db_name); + + /* Close object */ + if (pos + 1 < buf_size) + { + buf[pos++] = '}'; + buf[pos] = '\0'; + } + + return pos; +} diff --git a/network/game_state.h b/network/game_state.h new file mode 100644 index 000000000000..ed16ef2f6e66 --- /dev/null +++ b/network/game_state.h @@ -0,0 +1,147 @@ +/* RetroArch - A frontend for libretro. + * Copyright (C) 2024 - libretro team + * + * RetroArch is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * RetroArch is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with RetroArch. If not, see . + */ + +/* + * game_state.h – In-memory store for the game currently being played. + * + * Provides a thread-safe, single-instance record of the active game. + * Call game_state_set() when a game starts and game_state_clear() when + * it ends. game_state_to_json() serialises the current record as a + * JSON object suitable for sending over the WebSocket server. + */ + +#ifndef __RARCH_GAME_STATE_H +#define __RARCH_GAME_STATE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Maximum lengths for each field (including NUL terminator). */ +#define GAME_STATE_GAME_ID_LEN 64 +#define GAME_STATE_GAME_NAME_LEN 512 +#define GAME_STATE_GAME_PATH_LEN 4096 +#define GAME_STATE_CONSOLE_ID_LEN 64 +#define GAME_STATE_CONSOLE_NAME_LEN 256 +#define GAME_STATE_CORE_NAME_LEN 256 +#define GAME_STATE_DB_NAME_LEN 512 + +/** + * ra_game_state_t: + * + * Describes the game that is currently loaded in RetroArch. + * + * game_id – CRC-32 checksum of the ROM as a hex string (may be + * empty if unknown). + * game_name – Base filename of the ROM without extension. + * game_path – Full filesystem path to the ROM. + * console_id – Short system/platform identifier supplied by the + * core info database (e.g. "snes", "megadrive"). + * console_name – Human-readable platform name (e.g. + * "Super Nintendo Entertainment System"). + * core_name – Name of the libretro core that is running the game. + * db_name – Playlist/database name associated with the content. + */ +typedef struct +{ + char game_id [GAME_STATE_GAME_ID_LEN]; + char game_name [GAME_STATE_GAME_NAME_LEN]; + char game_path [GAME_STATE_GAME_PATH_LEN]; + char console_id [GAME_STATE_CONSOLE_ID_LEN]; + char console_name [GAME_STATE_CONSOLE_NAME_LEN]; + char core_name [GAME_STATE_CORE_NAME_LEN]; + char db_name [GAME_STATE_DB_NAME_LEN]; +} ra_game_state_t; + +/** + * game_state_init: + * + * Initialises internal resources (mutex). Must be called once before + * any other game_state_* function. Safe to call multiple times. + */ +void game_state_init(void); + +/** + * game_state_deinit: + * + * Releases internal resources. After this call game_state_init() must + * be called again before the API is used. + */ +void game_state_deinit(void); + +/** + * game_state_set: + * @state : pointer to the state to store (copied internally). + * + * Records @state as the active game. Thread-safe. + */ +void game_state_set(const ra_game_state_t *state); + +/** + * game_state_clear: + * + * Marks the current state as "no game running". Thread-safe. + */ +void game_state_clear(void); + +/** + * game_state_is_running: + * + * Returns true when a game is currently set, false otherwise. + * Thread-safe. + */ +bool game_state_is_running(void); + +/** + * game_state_get: + * @out : destination struct to fill (must not be NULL). + * + * Copies the current state into @out. + * Returns true when a game is active, false when no game is set (in + * which case @out is not modified). Thread-safe. + */ +bool game_state_get(ra_game_state_t *out); + +/** + * game_state_to_json: + * @buf : destination buffer. + * @buf_size : total size of @buf in bytes. + * + * Serialises the current state as a JSON object into @buf. + * + * When a game is running the object has the shape: + * { "type":"game_playing", + * "game_id":"...", "game_name":"...", "game_path":"...", + * "console_id":"...", "console_name":"...", + * "core_name":"...", "db_name":"..." } + * + * When no game is running: + * { "type":"no_game" } + * + * Returns the number of bytes written to @buf (excluding the NUL + * terminator), or 0 on error. Thread-safe. + */ +size_t game_state_to_json(char *buf, size_t buf_size); + +#ifdef __cplusplus +} +#endif + +#endif /* __RARCH_GAME_STATE_H */ diff --git a/network/ws_server.c b/network/ws_server.c index bdaa503a0409..bf052ca3e35b 100644 --- a/network/ws_server.c +++ b/network/ws_server.c @@ -23,8 +23,22 @@ * thread owns the libwebsockets service loop, keeping it fully decoupled from * frame processing. * - * A single libwebsockets "retroarch" protocol is registered; derived projects - * can extend the callback to add application-level message handling. + * Game-state messaging + * -------------------- + * When a new client connects the server immediately sends it the current game + * state JSON (see game_state.h). When the active game changes the caller + * invokes ws_server_notify_game_changed(); the server thread then broadcasts + * the updated state to every connected client. + * + * Broadcast design + * ---------------- + * ws_server_notify_game_changed() is safe to call from any thread. It sets + * a flag under the existing g_lock and wakes up the service thread via + * lws_cancel_service(). The service thread checks the flag after each + * lws_service() call and, if set, calls lws_callback_on_writable_all_protocol() + * to schedule a LWS_CALLBACK_SERVER_WRITEABLE event for every connected + * client. The actual JSON write happens inside that callback, keeping all + * libwebsockets I/O on the service thread. * * Platform notes: * Linux : link with -lwebsockets (or use pkg-config libwebsockets). @@ -35,6 +49,7 @@ */ #include "ws_server.h" +#include "game_state.h" #include #include @@ -66,14 +81,45 @@ * the thread responsive to stop requests without busy-spinning. */ #define WS_SERVICE_TIMEOUT_MS 10 +/* Maximum JSON payload size (bytes) for a game-state message. + * game_path alone can be up to 4096 chars; add room for all other fields + * plus JSON syntax overhead. */ +#define WS_MSG_MAX_BYTES 8192 + /* ------------------------------------------------------------------------- * Internal state * ---------------------------------------------------------------------- */ -static struct lws_context *g_lws_ctx = NULL; -static sthread_t *g_thread = NULL; -static slock_t *g_lock = NULL; -static bool g_running = false; +static struct lws_context *g_lws_ctx = NULL; +static sthread_t *g_thread = NULL; +static slock_t *g_lock = NULL; +static bool g_running = false; +static bool g_broadcast_pending = false; + +/* ------------------------------------------------------------------------- + * Helper: write the current game state to a single client + * ---------------------------------------------------------------------- */ + +/** + * ws_write_game_state: + * @wsi : the WebSocket connection to write to. + * + * Serialises the current game state as JSON and sends it to @wsi. + * Must be called from within the libwebsockets service thread + * (i.e. from a LWS_CALLBACK_SERVER_WRITEABLE handler). + */ +static void ws_write_game_state(struct lws *wsi) +{ + /* libwebsockets requires LWS_PRE bytes of padding before the payload. */ + unsigned char buf[LWS_PRE + WS_MSG_MAX_BYTES]; + size_t len; + + len = game_state_to_json((char *)(buf + LWS_PRE), WS_MSG_MAX_BYTES); + if (len == 0) + return; + + lws_write(wsi, buf + LWS_PRE, len, LWS_WRITE_TEXT); +} /* ------------------------------------------------------------------------- * Protocol callback @@ -84,34 +130,24 @@ static int callback_retroarch(struct lws *wsi, void *user, void *in, size_t len) { (void)user; + (void)in; + (void)len; switch (reason) { case LWS_CALLBACK_ESTABLISHED: - /* A new client has connected. */ + /* A new client has connected. Schedule an immediate write so it + * receives the current game state without waiting for a broadcast. */ + lws_callback_on_writable(wsi); break; - case LWS_CALLBACK_RECEIVE: - /* Echo the received data back to the sender as a demonstration. - * Replace this block with real message-handling logic as needed. */ - if (in && len > 0) - { - /* LWS_PRE bytes of padding are required before the payload. */ - unsigned char *buf = (unsigned char *)malloc(LWS_PRE + len); - if (buf) - { - memcpy(buf + LWS_PRE, in, len); - lws_write(wsi, buf + LWS_PRE, len, LWS_WRITE_TEXT); - free(buf); - } - else - fprintf(stderr, "[ws_server] malloc failed for echo buffer " - "(%lu bytes).\n", (unsigned long)(LWS_PRE + len)); - } + case LWS_CALLBACK_SERVER_WRITEABLE: + /* Send the current game state to this client. */ + ws_write_game_state(wsi); break; case LWS_CALLBACK_CLOSED: - /* Client disconnected. */ + /* Client disconnected – nothing to clean up. */ break; default: @@ -146,14 +182,24 @@ static void ws_server_thread(void *userdata) for (;;) { bool running; + bool broadcast; slock_lock(g_lock); - running = g_running; + running = g_running; + broadcast = g_broadcast_pending; + if (broadcast) + g_broadcast_pending = false; slock_unlock(g_lock); if (!running) break; + /* If a broadcast was requested, schedule a writeable callback for + * every connected client before servicing events. This call is + * safe here because we are on the service thread. */ + if (broadcast) + lws_callback_on_writable_all_protocol(g_lws_ctx, &g_protocols[0]); + /* lws_service() blocks for at most WS_SERVICE_TIMEOUT_MS milliseconds, * then returns so we can re-check the stop flag. */ lws_service(g_lws_ctx, WS_SERVICE_TIMEOUT_MS); @@ -199,7 +245,8 @@ bool ws_server_init(unsigned port) } slock_lock(g_lock); - g_running = true; + g_running = true; + g_broadcast_pending = false; slock_unlock(g_lock); g_thread = sthread_create(ws_server_thread, NULL); @@ -252,3 +299,17 @@ void ws_server_destroy(void) lws_context_destroy(g_lws_ctx); g_lws_ctx = NULL; } + +void ws_server_notify_game_changed(void) +{ + if (!g_lws_ctx || !g_lock) + return; + + /* Set the broadcast flag under the lock so the service thread picks it + * up safely, then wake the service loop. */ + slock_lock(g_lock); + g_broadcast_pending = true; + slock_unlock(g_lock); + + lws_cancel_service(g_lws_ctx); +} diff --git a/network/ws_server.h b/network/ws_server.h index ff0400e28387..c3cde52a7302 100644 --- a/network/ws_server.h +++ b/network/ws_server.h @@ -48,6 +48,19 @@ bool ws_server_init(unsigned port); */ void ws_server_destroy(void); +/** + * ws_server_notify_game_changed: + * + * Broadcasts the current game state (obtained from game_state_to_json()) to + * all connected WebSocket clients. The message is sent asynchronously by + * the background service thread; this function returns immediately. + * + * Call this whenever the active game changes (start or stop). Safe to call + * from any thread while the server is running; no-op when the server is not + * initialised. + */ +void ws_server_notify_game_changed(void); + #ifdef __cplusplus } #endif diff --git a/retroarch.c b/retroarch.c index 68c0d107d1ce..1f451de07c5b 100644 --- a/retroarch.c +++ b/retroarch.c @@ -177,6 +177,7 @@ #ifdef HAVE_WEBSOCKET_SERVER #include "network/ws_server.h" +#include "network/game_state.h" #endif #ifdef HAVE_THREADS @@ -3675,6 +3676,12 @@ bool command_event(enum event_command cmd, void *data) runloop_st->flags &= ~RUNLOOP_FLAG_CORE_RUNNING; +#ifdef HAVE_WEBSOCKET_SERVER + /* Notify WebSocket clients that no game is running. */ + game_state_clear(); + ws_server_notify_game_changed(); +#endif + /* The platform that uses ram_state_save calls it when the content * ends and writes it to a file */ ram_state_to_file(); @@ -5949,6 +5956,7 @@ void main_exit(void *args) #endif #ifdef HAVE_WEBSOCKET_SERVER ws_server_destroy(); + game_state_deinit(); #endif retroarch_ctl(RARCH_CTL_MAIN_DEINIT, NULL); @@ -6193,6 +6201,7 @@ int rarch_main(int argc, char *argv[], void *data) task_push_cloud_sync(); #endif #ifdef HAVE_WEBSOCKET_SERVER + game_state_init(); ws_server_init(RARCH_DEFAULT_WEBSOCKET_PORT); #endif #ifdef HAVE_LAKKA diff --git a/tasks/task_content.c b/tasks/task_content.c index b5205cde60c4..e76a5e0d3ffa 100644 --- a/tasks/task_content.c +++ b/tasks/task_content.c @@ -105,6 +105,11 @@ #include "../network/presence.h" #endif +#ifdef HAVE_WEBSOCKET_SERVER +#include "../network/game_state.h" +#include "../network/ws_server.h" +#endif + #define MAX_ARGS 32 typedef struct content_stream content_stream_t; @@ -1273,6 +1278,76 @@ static bool content_file_load( return false; } +#ifdef HAVE_WEBSOCKET_SERVER + /* Game loaded successfully: update the in-memory game state and + * broadcast it to all connected WebSocket clients. */ + { + ra_game_state_t new_state; + core_info_t *core_info = NULL; + rarch_system_info_t *sys_info = &runloop_state_get_ptr()->system; + const char *s_name = NULL; + + memset(&new_state, 0, sizeof(new_state)); + + /* Game ID – CRC-32 supplied by the companion UI (may be empty) */ + if (!string_is_empty(p_content->companion_ui_crc32)) + strlcpy(new_state.game_id, p_content->companion_ui_crc32, + sizeof(new_state.game_id)); + + /* Game name (basename without extension) and full path */ + if (p_content->content_list && p_content->content_list->size > 0) + { + content_file_info_t *fi = &p_content->content_list->entries[0]; + if (!string_is_empty(fi->name)) + strlcpy(new_state.game_name, fi->name, + sizeof(new_state.game_name)); + if (!string_is_empty(fi->full_path)) + strlcpy(new_state.game_path, fi->full_path, + sizeof(new_state.game_path)); + } + + /* Database / playlist name */ + if (!string_is_empty(p_content->companion_ui_db_name)) + strlcpy(new_state.db_name, p_content->companion_ui_db_name, + sizeof(new_state.db_name)); + + /* Console and core info from the loaded core's info file */ + core_info_get_current_core(&core_info); + if (core_info) + { + if (!string_is_empty(core_info->system_id)) + strlcpy(new_state.console_id, core_info->system_id, + sizeof(new_state.console_id)); + + s_name = core_info->systemname; + if (string_is_empty(s_name)) + s_name = core_info->display_name; + if (!string_is_empty(s_name)) + strlcpy(new_state.console_name, s_name, + sizeof(new_state.console_name)); + + s_name = core_info->core_name; + if (string_is_empty(s_name)) + s_name = core_info->display_name; + if (!string_is_empty(s_name)) + strlcpy(new_state.core_name, s_name, + sizeof(new_state.core_name)); + } + + /* Fall back to the core's self-reported library name when the + * core info file is not available. */ + if (string_is_empty(new_state.console_name)) + strlcpy(new_state.console_name, sys_info->info.library_name, + sizeof(new_state.console_name)); + if (string_is_empty(new_state.core_name)) + strlcpy(new_state.core_name, sys_info->info.library_name, + sizeof(new_state.core_name)); + + game_state_set(&new_state); + ws_server_notify_game_changed(); + } +#endif + #ifdef HAVE_CHEEVOS if (!special) { From 77867eea141922dc28c7048ba1482018047390f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:12:39 +0000 Subject: [PATCH 03/37] Fix misleading comment in json_append_field buffer check Co-authored-by: kzryzstof <38137839+kzryzstof@users.noreply.github.com> Agent-Logs-Url: https://github.com/Octelys/retro-arch/sessions/4d5348fa-3720-4052-8411-78781fe81983 --- network/game_state.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/network/game_state.c b/network/game_state.c index 62bb43e08d81..33fccd733e1c 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -85,11 +85,13 @@ static void json_append_field(char *buf, size_t *pos, if (n > 0) *pos += (size_t)n; - /* Escape and copy the value character-by-character */ + /* Escape and copy the value character-by-character. + * Reserve 2 bytes for a potential 2-byte escape sequence ('\\' + char); + * the closing '"' is appended separately after the loop. */ if (!string_is_empty(value)) { const unsigned char *src = (const unsigned char *)value; - while (*src && *pos + 3 < buf_size) /* +3: escape + char + closing " */ + while (*src && *pos + 2 < buf_size) { unsigned char c = *src++; if (c == '"' || c == '\\') From a42d6dfa9cb2c6ac7603cdd22e1f21065038125f Mon Sep 17 00:00:00 2001 From: kzryzstof Date: Sat, 21 Mar 2026 10:06:20 -0400 Subject: [PATCH 04/37] Refactor RetroAchievements integration: centralize WebSocket state updates and clean up legacy code --- cheevos/cheevos.c | 12 ++++++++ network/game_state.c | 55 +++++++++++++++++++++++++++++++++ network/game_state.h | 25 +++++++++++++++ tasks/task_content.c | 73 -------------------------------------------- 4 files changed, 92 insertions(+), 73 deletions(-) diff --git a/cheevos/cheevos.c b/cheevos/cheevos.c index 8f889d6d6200..a0919116d5f4 100644 --- a/cheevos/cheevos.c +++ b/cheevos/cheevos.c @@ -82,6 +82,11 @@ #include "../deps/rcheevos/include/rc_hash.h" #include "../deps/rcheevos/src/rc_libretro.h" +#ifdef HAVE_WEBSOCKET_SERVER +#include "../network/game_state.h" +#include "../network/ws_server.h" +#endif + /* Define this macro to prevent cheevos from being deactivated when they trigger. */ #undef CHEEVOS_DONT_DEACTIVATE @@ -1162,6 +1167,7 @@ const char* rcheevos_get_hash(void) return game ? game->hash : msg_hash_to_str(MENU_ENUM_LABEL_VALUE_NOT_AVAILABLE); } + /* hooks for rc_hash library */ static void* rc_hash_handle_file_open(const char* path) @@ -1716,6 +1722,12 @@ static void rcheevos_client_load_game_callback(int result, rcheevos_spectating_changed(); /* synchronize spectating state */ +#ifdef HAVE_WEBSOCKET_SERVER + /* Now that the async RA lookup is complete, hand everything to + * game_state — it builds and broadcasts the full record. */ + game_state_update_from_cheevos(game, path_get(RARCH_PATH_CONTENT)); +#endif + #ifdef HAVE_THREADS /* Have to "schedule" this. Game image should not be * loaded into memory on background thread */ diff --git a/network/game_state.c b/network/game_state.c index 33fccd733e1c..ffca52ef3a48 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -32,6 +32,12 @@ #include #include +#ifdef HAVE_CHEEVOS +#include "../deps/rcheevos/include/rc_client.h" +#include "../deps/rcheevos/include/rc_consoles.h" +#include "ws_server.h" +#endif + /* ------------------------------------------------------------------------- * Internal state * ---------------------------------------------------------------------- */ @@ -211,3 +217,52 @@ size_t game_state_to_json(char *buf, size_t buf_size) return pos; } + +#ifdef HAVE_CHEEVOS +/** + * game_state_update_from_cheevos: + * + * Sole entry-point for populating and broadcasting the WebSocket game + * state. Called from rcheevos_client_load_game_callback() once the + * async RetroAchievements lookup has completed. Builds a fresh + * ra_game_state_t entirely from RA data + the ROM path: + * + * id → game_id (authoritative numeric RA game ID) + * title → game_name (RA-canonical title) + * console_id → console_name (human-readable via rc_console_name()) + * game_path → game_path (full filesystem path to the ROM) + */ +void game_state_update_from_cheevos(const rc_client_game_t *game, + const char *game_path) +{ + ra_game_state_t state; + + if (!game || game->id == 0) + return; + + memset(&state, 0, sizeof(state)); + + /* Authoritative numeric RA game ID */ + snprintf(state.game_id, sizeof(state.game_id), "%u", (unsigned)game->id); + + /* RA-canonical game title */ + if (!string_is_empty(game->title)) + strlcpy(state.game_name, game->title, sizeof(state.game_name)); + + /* Full filesystem path to the ROM */ + if (!string_is_empty(game_path)) + strlcpy(state.game_path, game_path, sizeof(state.game_path)); + + /* Human-readable console name */ + if (game->console_id != 0) + { + const char *con = rc_console_name(game->console_id); + if (!string_is_empty(con)) + strlcpy(state.console_name, con, sizeof(state.console_name)); + } + + game_state_set(&state); + ws_server_notify_game_changed(); +} +#endif + diff --git a/network/game_state.h b/network/game_state.h index ed16ef2f6e66..d7e842fe7508 100644 --- a/network/game_state.h +++ b/network/game_state.h @@ -30,6 +30,17 @@ #include #include +#ifdef HAVE_CHEEVOS +/* Forward-declare rc_client_game_t so callers do not need to include + * the full rcheevos headers. When rc_client.h has already been + * included the struct tag already exists, so the forward-declaration + * is harmless; the typedef is guarded to avoid a duplicate-typedef + * error in C99/C11. */ +#ifndef RC_CLIENT_H /* rc_client.h defines this guard */ +typedef struct rc_client_game_t rc_client_game_t; +#endif +#endif + #ifdef __cplusplus extern "C" { #endif @@ -140,6 +151,20 @@ bool game_state_get(ra_game_state_t *out); */ size_t game_state_to_json(char *buf, size_t buf_size); +#ifdef HAVE_CHEEVOS +/** + * game_state_update_from_cheevos: + * @game : rc_client_game_t returned by rc_client_get_game_info(). + * @game_path : full filesystem path to the ROM. + * + * Sole entry-point for populating and broadcasting the WebSocket game + * state. Builds a fresh ra_game_state_t from the RA data and + * game_path, then calls game_state_set() + ws_server_notify_game_changed(). + * Thread-safe. + */ +void game_state_update_from_cheevos(const rc_client_game_t *game, const char *game_path); +#endif + #ifdef __cplusplus } #endif diff --git a/tasks/task_content.c b/tasks/task_content.c index e76a5e0d3ffa..aef2610db941 100644 --- a/tasks/task_content.c +++ b/tasks/task_content.c @@ -105,10 +105,6 @@ #include "../network/presence.h" #endif -#ifdef HAVE_WEBSOCKET_SERVER -#include "../network/game_state.h" -#include "../network/ws_server.h" -#endif #define MAX_ARGS 32 @@ -1278,75 +1274,6 @@ static bool content_file_load( return false; } -#ifdef HAVE_WEBSOCKET_SERVER - /* Game loaded successfully: update the in-memory game state and - * broadcast it to all connected WebSocket clients. */ - { - ra_game_state_t new_state; - core_info_t *core_info = NULL; - rarch_system_info_t *sys_info = &runloop_state_get_ptr()->system; - const char *s_name = NULL; - - memset(&new_state, 0, sizeof(new_state)); - - /* Game ID – CRC-32 supplied by the companion UI (may be empty) */ - if (!string_is_empty(p_content->companion_ui_crc32)) - strlcpy(new_state.game_id, p_content->companion_ui_crc32, - sizeof(new_state.game_id)); - - /* Game name (basename without extension) and full path */ - if (p_content->content_list && p_content->content_list->size > 0) - { - content_file_info_t *fi = &p_content->content_list->entries[0]; - if (!string_is_empty(fi->name)) - strlcpy(new_state.game_name, fi->name, - sizeof(new_state.game_name)); - if (!string_is_empty(fi->full_path)) - strlcpy(new_state.game_path, fi->full_path, - sizeof(new_state.game_path)); - } - - /* Database / playlist name */ - if (!string_is_empty(p_content->companion_ui_db_name)) - strlcpy(new_state.db_name, p_content->companion_ui_db_name, - sizeof(new_state.db_name)); - - /* Console and core info from the loaded core's info file */ - core_info_get_current_core(&core_info); - if (core_info) - { - if (!string_is_empty(core_info->system_id)) - strlcpy(new_state.console_id, core_info->system_id, - sizeof(new_state.console_id)); - - s_name = core_info->systemname; - if (string_is_empty(s_name)) - s_name = core_info->display_name; - if (!string_is_empty(s_name)) - strlcpy(new_state.console_name, s_name, - sizeof(new_state.console_name)); - - s_name = core_info->core_name; - if (string_is_empty(s_name)) - s_name = core_info->display_name; - if (!string_is_empty(s_name)) - strlcpy(new_state.core_name, s_name, - sizeof(new_state.core_name)); - } - - /* Fall back to the core's self-reported library name when the - * core info file is not available. */ - if (string_is_empty(new_state.console_name)) - strlcpy(new_state.console_name, sys_info->info.library_name, - sizeof(new_state.console_name)); - if (string_is_empty(new_state.core_name)) - strlcpy(new_state.core_name, sys_info->info.library_name, - sizeof(new_state.core_name)); - - game_state_set(&new_state); - ws_server_notify_game_changed(); - } -#endif #ifdef HAVE_CHEEVOS if (!special) From a86209fbaa22672671dd45810125cf28a7bca0e6 Mon Sep 17 00:00:00 2001 From: kzryzstof Date: Sat, 21 Mar 2026 10:54:21 -0400 Subject: [PATCH 05/37] Add WebSocket support for broadcasting RetroAchievements data --- network/game_state.c | 88 +++++++++++++++++++++++++ network/game_state.h | 37 +++++++++-- network/ws_server.c | 153 ++++++++++++++++++++++++++++++++++++------- network/ws_server.h | 11 ++++ 4 files changed, 258 insertions(+), 31 deletions(-) diff --git a/network/game_state.c b/network/game_state.c index ffca52ef3a48..902446f4c2cf 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -263,6 +263,94 @@ void game_state_update_from_cheevos(const rc_client_game_t *game, game_state_set(&state); ws_server_notify_game_changed(); + ws_server_notify_achievements_loaded(); +} + +/** + * game_state_achievements_to_json: + * + * Iterates all core achievements for the loaded game and serializes + * them as a JSON object. Each entry contains: + * id – numeric achievement ID + * name – achievement title + * points – point value + * status – "unlocked" or "locked" + * badge_url – unlocked badge image URL (omitted when unavailable) + */ +size_t game_state_achievements_to_json(const rc_client_t *client, + char *buf, size_t buf_size) +{ + rc_client_achievement_list_t *list; + size_t pos = 0; + int n; + uint32_t bi, ai; + bool first = true; + + if (!client || !buf || buf_size < 2) + return 0; + + /* Open the envelope */ + n = snprintf(buf, buf_size, "{\"type\":\"achievements\",\"items\":["); + if (n <= 0) + return 0; + pos = (size_t)n; + + /* Enumerate core achievements grouped by lock-state bucket */ + list = rc_client_create_achievement_list( + (rc_client_t *)client, + RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, + RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE); + + if (list) + { + for (bi = 0; bi < list->num_buckets; bi++) + { + const rc_client_achievement_bucket_t *bucket = &list->buckets[bi]; + for (ai = 0; ai < bucket->num_achievements; ai++) + { + const rc_client_achievement_t *ach = bucket->achievements[ai]; + const char *status = + (ach->unlocked != RC_CLIENT_ACHIEVEMENT_UNLOCKED_NONE) + ? "unlocked" : "locked"; + + /* Separator between items */ + if (!first) + { + if (pos + 1 < buf_size) + buf[pos++] = ','; + } + first = false; + + /* id, name, points, status */ + n = snprintf(buf + pos, buf_size - pos, + "{\"id\":%u,\"points\":%u,\"status\":\"%s\"", + (unsigned)ach->id, (unsigned)ach->points, status); + if (n > 0) + pos += (size_t)n; + + /* name – JSON-escaped via json_append_field */ + json_append_field(buf, &pos, buf_size, "name", ach->title); + + /* badge_url – use the unlocked URL when present */ + if (!string_is_empty(ach->badge_url)) + json_append_field(buf, &pos, buf_size, "badge_url", ach->badge_url); + + /* Close the achievement object */ + if (pos + 1 < buf_size) + buf[pos++] = '}'; + } + } + + rc_client_destroy_achievement_list(list); + } + + /* Close array and object */ + n = snprintf(buf + pos, buf_size - pos, "]}"); + if (n > 0) + pos += (size_t)n; + + buf[pos < buf_size ? pos : buf_size - 1] = '\0'; + return pos; } #endif diff --git a/network/game_state.h b/network/game_state.h index d7e842fe7508..65ac6734615c 100644 --- a/network/game_state.h +++ b/network/game_state.h @@ -31,12 +31,11 @@ #include #ifdef HAVE_CHEEVOS -/* Forward-declare rc_client_game_t so callers do not need to include - * the full rcheevos headers. When rc_client.h has already been - * included the struct tag already exists, so the forward-declaration - * is harmless; the typedef is guarded to avoid a duplicate-typedef - * error in C99/C11. */ -#ifndef RC_CLIENT_H /* rc_client.h defines this guard */ +/* Forward-declare rcheevos types so callers do not need to pull in the + * full rc_client.h. The typedefs are skipped when rc_client.h has + * already been included (it defines RC_CLIENT_H) to avoid duplicates. */ +#ifndef RC_CLIENT_H +typedef struct rc_client_t rc_client_t; typedef struct rc_client_game_t rc_client_game_t; #endif #endif @@ -163,6 +162,32 @@ size_t game_state_to_json(char *buf, size_t buf_size); * Thread-safe. */ void game_state_update_from_cheevos(const rc_client_game_t *game, const char *game_path); + +/** + * game_state_achievements_to_json: + * @client : the rc_client_t that owns the loaded game. + * @buf : destination buffer. + * @buf_size : total size of @buf in bytes. + * + * Serialises all core achievements for the currently loaded game as a + * JSON object of the shape: + * { "type":"achievements", + * "items": [ + * { "id":1, "name":"...", "points":5, + * "status":"unlocked", + * "badge_url":"https://..." }, + * ... + * ] } + * + * "status" is "unlocked" when the achievement has been earned (softcore + * or hardcore), "locked" otherwise. + * "badge_url" is the unlocked badge URL when available, otherwise omitted. + * + * Returns the number of bytes written (excluding NUL), or 0 on error. + */ +size_t game_state_achievements_to_json(const rc_client_t *client, + char *buf, size_t buf_size); + #endif #ifdef __cplusplus diff --git a/network/ws_server.c b/network/ws_server.c index bf052ca3e35b..0a57b9350039 100644 --- a/network/ws_server.c +++ b/network/ws_server.c @@ -51,6 +51,10 @@ #include "ws_server.h" #include "game_state.h" +#ifdef HAVE_CHEEVOS +#include "../cheevos/cheevos_locals.h" +#endif + #include #include @@ -86,15 +90,44 @@ * plus JSON syntax overhead. */ #define WS_MSG_MAX_BYTES 8192 +/* Maximum JSON payload for the achievements message. A game with ~400 + * achievements, each with a title (~60 chars) and badge URL (~80 chars), + * needs roughly 400 * 250 = 100 KB. Use 256 KB to be safe. */ +#define WS_ACH_MSG_MAX_BYTES (256 * 1024) + +/* ------------------------------------------------------------------------- + * Per-session write state + * + * libwebsockets fires LWS_CALLBACK_SERVER_WRITEABLE once per scheduled + * wsi. We use per-session data to remember which message the client + * still needs so that both game-state and achievements are delivered in + * order without re-scheduling a broadcast for every client separately. + * ---------------------------------------------------------------------- */ + +typedef enum { + WS_WRITE_IDLE = 0, /* nothing queued */ + WS_WRITE_GAME_STATE = 1, /* send game-state next */ + WS_WRITE_ACHIEVEMENTS = 2 /* send achievements next */ +} ws_write_state_t; + +typedef struct { + ws_write_state_t next_write; +} ws_session_data_t; + /* ------------------------------------------------------------------------- * Internal state * ---------------------------------------------------------------------- */ -static struct lws_context *g_lws_ctx = NULL; -static sthread_t *g_thread = NULL; -static slock_t *g_lock = NULL; -static bool g_running = false; -static bool g_broadcast_pending = false; +static struct lws_context *g_lws_ctx = NULL; +static sthread_t *g_thread = NULL; +static slock_t *g_lock = NULL; +static bool g_running = false; +static bool g_broadcast_pending = false; +static bool g_ach_pending = false; + +/* Tells the WRITEABLE callback which message to deliver during a broadcast. + * Only read/written on the service thread after draining the flags above. */ +static ws_write_state_t g_broadcast_type = WS_WRITE_IDLE; /* ------------------------------------------------------------------------- * Helper: write the current game state to a single client @@ -121,6 +154,40 @@ static void ws_write_game_state(struct lws *wsi) lws_write(wsi, buf + LWS_PRE, len, LWS_WRITE_TEXT); } +/** + * ws_write_achievements: + * @wsi : the WebSocket connection to write to. + * + * Serialises the achievements list as JSON and sends it to @wsi. + * Must be called from within the libwebsockets service thread. + */ +static void ws_write_achievements(struct lws *wsi) +{ +#ifdef HAVE_CHEEVOS + unsigned char *buf; + size_t len; + + buf = (unsigned char *)malloc(LWS_PRE + WS_ACH_MSG_MAX_BYTES); + if (!buf) + return; + + { + const rcheevos_locals_t *locals = get_rcheevos_locals(); + len = game_state_achievements_to_json( + locals ? locals->client : NULL, + (char *)(buf + LWS_PRE), + WS_ACH_MSG_MAX_BYTES); + } + + if (len > 0) + lws_write(wsi, buf + LWS_PRE, len, LWS_WRITE_TEXT); + + free(buf); +#else + (void)wsi; +#endif +} + /* ------------------------------------------------------------------------- * Protocol callback * ---------------------------------------------------------------------- */ @@ -129,25 +196,42 @@ static int callback_retroarch(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { - (void)user; + ws_session_data_t *session = (ws_session_data_t *)user; + (void)in; (void)len; switch (reason) { case LWS_CALLBACK_ESTABLISHED: - /* A new client has connected. Schedule an immediate write so it - * receives the current game state without waiting for a broadcast. */ + /* A new client connected: queue game-state first. */ + if (session) + session->next_write = WS_WRITE_GAME_STATE; lws_callback_on_writable(wsi); break; case LWS_CALLBACK_SERVER_WRITEABLE: - /* Send the current game state to this client. */ - ws_write_game_state(wsi); + if (!session) + break; + /* If this write was triggered by a broadcast (session is IDLE), + * adopt the global broadcast type first. */ + if (session->next_write == WS_WRITE_IDLE) + session->next_write = g_broadcast_type; + + if (session->next_write == WS_WRITE_GAME_STATE) + { + ws_write_game_state(wsi); + session->next_write = WS_WRITE_ACHIEVEMENTS; + lws_callback_on_writable(wsi); + } + else if (session->next_write == WS_WRITE_ACHIEVEMENTS) + { + ws_write_achievements(wsi); + session->next_write = WS_WRITE_IDLE; + } break; case LWS_CALLBACK_CLOSED: - /* Client disconnected – nothing to clean up. */ break; default: @@ -163,12 +247,12 @@ static int callback_retroarch(struct lws *wsi, static struct lws_protocols g_protocols[] = { { - "retroarch", /* protocol name */ - callback_retroarch, /* callback */ - 0, /* per-session data size */ - WS_RX_BUFFER_BYTES /* rx buffer size */ + "retroarch", + callback_retroarch, + sizeof(ws_session_data_t), /* per-session data size */ + WS_RX_BUFFER_BYTES }, - { NULL, NULL, 0, 0 } /* terminator – compatible with all lws versions */ + { NULL, NULL, 0, 0 } }; /* ------------------------------------------------------------------------- @@ -183,25 +267,34 @@ static void ws_server_thread(void *userdata) { bool running; bool broadcast; + bool ach_broadcast; slock_lock(g_lock); - running = g_running; - broadcast = g_broadcast_pending; + running = g_running; + broadcast = g_broadcast_pending; + ach_broadcast = g_ach_pending; if (broadcast) g_broadcast_pending = false; + if (ach_broadcast) + g_ach_pending = false; slock_unlock(g_lock); if (!running) break; - /* If a broadcast was requested, schedule a writeable callback for - * every connected client before servicing events. This call is - * safe here because we are on the service thread. */ + /* Game-state broadcast: send game_state then achievements to all. */ if (broadcast) + { + g_broadcast_type = WS_WRITE_GAME_STATE; lws_callback_on_writable_all_protocol(g_lws_ctx, &g_protocols[0]); + } + /* Achievements-only broadcast (e.g. unlock update in future). */ + else if (ach_broadcast) + { + g_broadcast_type = WS_WRITE_ACHIEVEMENTS; + lws_callback_on_writable_all_protocol(g_lws_ctx, &g_protocols[0]); + } - /* lws_service() blocks for at most WS_SERVICE_TIMEOUT_MS milliseconds, - * then returns so we can re-check the stop flag. */ lws_service(g_lws_ctx, WS_SERVICE_TIMEOUT_MS); } } @@ -305,11 +398,21 @@ void ws_server_notify_game_changed(void) if (!g_lws_ctx || !g_lock) return; - /* Set the broadcast flag under the lock so the service thread picks it - * up safely, then wake the service loop. */ slock_lock(g_lock); g_broadcast_pending = true; slock_unlock(g_lock); lws_cancel_service(g_lws_ctx); } + +void ws_server_notify_achievements_loaded(void) +{ + if (!g_lws_ctx || !g_lock) + return; + + slock_lock(g_lock); + g_ach_pending = true; + slock_unlock(g_lock); + + lws_cancel_service(g_lws_ctx); +} diff --git a/network/ws_server.h b/network/ws_server.h index c3cde52a7302..6a226a0b232d 100644 --- a/network/ws_server.h +++ b/network/ws_server.h @@ -61,6 +61,17 @@ void ws_server_destroy(void); */ void ws_server_notify_game_changed(void); +/** + * ws_server_notify_achievements_loaded: + * + * Broadcasts the achievements list (obtained from + * game_state_achievements_to_json()) to all connected WebSocket clients. + * Sent as a second message immediately after ws_server_notify_game_changed() + * when a new game is identified. Safe to call from any thread while the + * server is running; no-op when the server is not initialised. + */ +void ws_server_notify_achievements_loaded(void); + #ifdef __cplusplus } #endif From 2cd683f81913dc7cee324a072bbf1e45fcae6917 Mon Sep 17 00:00:00 2001 From: kzryzstof Date: Sat, 21 Mar 2026 13:25:50 -0400 Subject: [PATCH 06/37] Refactor WebSocket server: simplify message queuing, unify game state and achievements broadcasts --- cheevos/cheevos.c | 6 ++ network/game_state.c | 1 - network/ws_server.c | 144 ++++++++++++++++++++++++++----------------- network/ws_server.h | 20 +++--- 4 files changed, 102 insertions(+), 69 deletions(-) diff --git a/cheevos/cheevos.c b/cheevos/cheevos.c index a0919116d5f4..2b51c44860b5 100644 --- a/cheevos/cheevos.c +++ b/cheevos/cheevos.c @@ -482,6 +482,12 @@ static void rcheevos_award_achievement(const rc_client_achievement_t* cheevo) } } #endif + +#ifdef HAVE_WEBSOCKET_SERVER + /* Broadcast the updated achievement list so connected clients + * immediately see the new unlock status. */ + ws_server_notify_achievements_changed(); +#endif } static void rcheevos_lboard_submitted(const rc_client_leaderboard_t* lboard, diff --git a/network/game_state.c b/network/game_state.c index 902446f4c2cf..53bb8af5dbbf 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -263,7 +263,6 @@ void game_state_update_from_cheevos(const rc_client_game_t *game, game_state_set(&state); ws_server_notify_game_changed(); - ws_server_notify_achievements_loaded(); } /** diff --git a/network/ws_server.c b/network/ws_server.c index 0a57b9350039..d1d9bf3cb863 100644 --- a/network/ws_server.c +++ b/network/ws_server.c @@ -96,38 +96,39 @@ #define WS_ACH_MSG_MAX_BYTES (256 * 1024) /* ------------------------------------------------------------------------- - * Per-session write state - * - * libwebsockets fires LWS_CALLBACK_SERVER_WRITEABLE once per scheduled - * wsi. We use per-session data to remember which message the client - * still needs so that both game-state and achievements are delivered in - * order without re-scheduling a broadcast for every client separately. + * Internal state * ---------------------------------------------------------------------- */ -typedef enum { - WS_WRITE_IDLE = 0, /* nothing queued */ - WS_WRITE_GAME_STATE = 1, /* send game-state next */ - WS_WRITE_ACHIEVEMENTS = 2 /* send achievements next */ -} ws_write_state_t; +static struct lws_context *g_lws_ctx = NULL; +static sthread_t *g_thread = NULL; +static slock_t *g_lock = NULL; +static bool g_running = false; +static bool g_game_broadcast_pending = false; +static bool g_ach_broadcast_pending = false; -typedef struct { - ws_write_state_t next_write; -} ws_session_data_t; +/* Written by the service thread before lws_callback_on_writable_all_protocol, + * read inside the WRITEABLE callback — both on the same service thread. + * Holds a WS_MSG_* bitmask indicating which messages the broadcast delivers. */ +static int g_broadcast_kind = 0; /* ------------------------------------------------------------------------- - * Internal state + * Per-session data + * + * pending_messages: bitmask of messages yet to be sent to this client. + * WS_MSG_GAME_STATE (bit 0) – game state JSON + * WS_MSG_ACHIEVEMENTS (bit 1) – achievements JSON + * + * Each WRITEABLE invocation sends exactly ONE message and clears its bit. + * If more bits remain it calls lws_callback_on_writable() to schedule + * the next write. This ensures lws never has to retry a partial write, + * which is what causes the infinite WRITEABLE loop. * ---------------------------------------------------------------------- */ +#define WS_MSG_GAME_STATE (1 << 0) +#define WS_MSG_ACHIEVEMENTS (1 << 1) -static struct lws_context *g_lws_ctx = NULL; -static sthread_t *g_thread = NULL; -static slock_t *g_lock = NULL; -static bool g_running = false; -static bool g_broadcast_pending = false; -static bool g_ach_pending = false; - -/* Tells the WRITEABLE callback which message to deliver during a broadcast. - * Only read/written on the service thread after draining the flags above. */ -static ws_write_state_t g_broadcast_type = WS_WRITE_IDLE; +typedef struct { + int pending_messages; +} ws_session_t; /* ------------------------------------------------------------------------- * Helper: write the current game state to a single client @@ -196,7 +197,7 @@ static int callback_retroarch(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { - ws_session_data_t *session = (ws_session_data_t *)user; + ws_session_t *session = (ws_session_t *)user; (void)in; (void)len; @@ -204,31 +205,46 @@ static int callback_retroarch(struct lws *wsi, switch (reason) { case LWS_CALLBACK_ESTABLISHED: - /* A new client connected: queue game-state first. */ + /* New client: queue both messages and request the first write. */ if (session) - session->next_write = WS_WRITE_GAME_STATE; + session->pending_messages = WS_MSG_GAME_STATE | WS_MSG_ACHIEVEMENTS; + fprintf(stderr, "[ws_server] CONNECTED\n"); lws_callback_on_writable(wsi); break; case LWS_CALLBACK_SERVER_WRITEABLE: if (!session) break; - /* If this write was triggered by a broadcast (session is IDLE), - * adopt the global broadcast type first. */ - if (session->next_write == WS_WRITE_IDLE) - session->next_write = g_broadcast_type; - if (session->next_write == WS_WRITE_GAME_STATE) + /* If nothing is queued on this session yet, this WRITEABLE was + * triggered by a broadcast — adopt the broadcast's message set + * and clear g_broadcast_kind so subsequent unsolicited WRITEABLE + * callbacks (fired while lws drains large payloads) don't + * re-adopt it. */ + if (session->pending_messages == 0) + { + session->pending_messages = g_broadcast_kind; + g_broadcast_kind = 0; + } + + /* Send exactly one message per WRITEABLE invocation. */ + if (session->pending_messages & WS_MSG_GAME_STATE) { + session->pending_messages &= ~WS_MSG_GAME_STATE; + fprintf(stderr, "[ws_server] WRITE GAME\n"); ws_write_game_state(wsi); - session->next_write = WS_WRITE_ACHIEVEMENTS; - lws_callback_on_writable(wsi); } - else if (session->next_write == WS_WRITE_ACHIEVEMENTS) + else if (session->pending_messages & WS_MSG_ACHIEVEMENTS) { + session->pending_messages &= ~WS_MSG_ACHIEVEMENTS; + fprintf(stderr, "[ws_server] WRITE ACHIEVEMENTS\n"); ws_write_achievements(wsi); - session->next_write = WS_WRITE_IDLE; } + + /* If more messages remain, schedule the next write. */ + if (session->pending_messages != 0) + lws_callback_on_writable(wsi); + break; case LWS_CALLBACK_CLOSED: @@ -249,7 +265,7 @@ static struct lws_protocols g_protocols[] = { { "retroarch", callback_retroarch, - sizeof(ws_session_data_t), /* per-session data size */ + sizeof(ws_session_t), /* per-session data size */ WS_RX_BUFFER_BYTES }, { NULL, NULL, 0, 0 } @@ -266,32 +282,40 @@ static void ws_server_thread(void *userdata) for (;;) { bool running; - bool broadcast; + bool game_broadcast; bool ach_broadcast; slock_lock(g_lock); - running = g_running; - broadcast = g_broadcast_pending; - ach_broadcast = g_ach_pending; - if (broadcast) - g_broadcast_pending = false; + running = g_running; + game_broadcast = g_game_broadcast_pending; + ach_broadcast = g_ach_broadcast_pending; + if (game_broadcast) + g_game_broadcast_pending = false; if (ach_broadcast) - g_ach_pending = false; + g_ach_broadcast_pending = false; slock_unlock(g_lock); if (!running) break; - /* Game-state broadcast: send game_state then achievements to all. */ - if (broadcast) + if (game_broadcast || ach_broadcast) { - g_broadcast_type = WS_WRITE_GAME_STATE; - lws_callback_on_writable_all_protocol(g_lws_ctx, &g_protocols[0]); - } - /* Achievements-only broadcast (e.g. unlock update in future). */ - else if (ach_broadcast) - { - g_broadcast_type = WS_WRITE_ACHIEVEMENTS; + /* Store the broadcast kind so the WRITEABLE callback knows which + * messages to send. Written here on the service thread, read and + * cleared inside the WRITEABLE callback which also runs on the + * service thread — so no races. + * + * We must NOT reset g_broadcast_kind here before lws_service(): + * the WRITEABLE callbacks are fired *inside* lws_service(), so + * they would always see 0 and skip the write. Instead the + * callback itself clears g_broadcast_kind after adopting it into + * the per-session pending_messages bitmask. That way any extra + * unsolicited WRITEABLE callbacks that lws fires while draining + * large payloads still find pending_messages == 0 and + * g_broadcast_kind == 0 and return without doing anything. */ + g_broadcast_kind = game_broadcast + ? (WS_MSG_GAME_STATE | WS_MSG_ACHIEVEMENTS) + : WS_MSG_ACHIEVEMENTS; lws_callback_on_writable_all_protocol(g_lws_ctx, &g_protocols[0]); } @@ -328,6 +352,7 @@ bool ws_server_init(unsigned port) return false; } + g_lock = slock_new(); if (!g_lock) { @@ -339,7 +364,8 @@ bool ws_server_init(unsigned port) slock_lock(g_lock); g_running = true; - g_broadcast_pending = false; + g_game_broadcast_pending = false; + g_ach_broadcast_pending = false; slock_unlock(g_lock); g_thread = sthread_create(ws_server_thread, NULL); @@ -399,20 +425,22 @@ void ws_server_notify_game_changed(void) return; slock_lock(g_lock); - g_broadcast_pending = true; + fprintf(stderr, "[ws_server] ws_server_notify_game_changed\n"); + g_game_broadcast_pending = true; slock_unlock(g_lock); lws_cancel_service(g_lws_ctx); } -void ws_server_notify_achievements_loaded(void) +void ws_server_notify_achievements_changed(void) { if (!g_lws_ctx || !g_lock) return; slock_lock(g_lock); - g_ach_pending = true; + g_ach_broadcast_pending = true; slock_unlock(g_lock); lws_cancel_service(g_lws_ctx); } + diff --git a/network/ws_server.h b/network/ws_server.h index 6a226a0b232d..78af84d492ec 100644 --- a/network/ws_server.h +++ b/network/ws_server.h @@ -51,9 +51,10 @@ void ws_server_destroy(void); /** * ws_server_notify_game_changed: * - * Broadcasts the current game state (obtained from game_state_to_json()) to - * all connected WebSocket clients. The message is sent asynchronously by - * the background service thread; this function returns immediately. + * Broadcasts the current game state (obtained from game_state_to_json()) + * followed immediately by the achievements list to all connected WebSocket + * clients. Both messages are sent asynchronously by the background service + * thread; this function returns immediately. * * Call this whenever the active game changes (start or stop). Safe to call * from any thread while the server is running; no-op when the server is not @@ -62,15 +63,14 @@ void ws_server_destroy(void); void ws_server_notify_game_changed(void); /** - * ws_server_notify_achievements_loaded: + * ws_server_notify_achievements_changed: * - * Broadcasts the achievements list (obtained from - * game_state_achievements_to_json()) to all connected WebSocket clients. - * Sent as a second message immediately after ws_server_notify_game_changed() - * when a new game is identified. Safe to call from any thread while the - * server is running; no-op when the server is not initialised. + * Broadcasts the updated achievements list to all connected WebSocket + * clients. Call this when an achievement is unlocked. Safe to call + * from any thread while the server is running. */ -void ws_server_notify_achievements_loaded(void); +void ws_server_notify_achievements_changed(void); + #ifdef __cplusplus } From 56dc0d39c717f92aa035ea78b54d79ee2ab8c99c Mon Sep 17 00:00:00 2001 From: kzryzstof Date: Sat, 21 Mar 2026 13:48:28 -0400 Subject: [PATCH 07/37] Add WebSocket broadcast support for RetroAchievements user information --- cheevos/cheevos.c | 6 +++ network/game_state.c | 109 +++++++++++++++++++++++++++++++++++++++++++ network/game_state.h | 29 ++++++++++++ network/ws_server.c | 84 ++++++++++++++++++++++++--------- network/ws_server.h | 10 ++++ 5 files changed, 217 insertions(+), 21 deletions(-) diff --git a/cheevos/cheevos.c b/cheevos/cheevos.c index 2b51c44860b5..8aa70f7401f0 100644 --- a/cheevos/cheevos.c +++ b/cheevos/cheevos.c @@ -1515,6 +1515,12 @@ static void rcheevos_client_login_callback(int result, runloop_msg_queue_push(msg, _len, 0, 2 * 60, false, NULL, MESSAGE_QUEUE_ICON_DEFAULT, MESSAGE_QUEUE_CATEGORY_INFO); } + +#ifdef HAVE_WEBSOCKET_SERVER + /* Broadcast the user info to any already-connected WebSocket clients. */ + game_state_set_user_from_cheevos(user); + ws_server_notify_user_changed(); +#endif } } diff --git a/network/game_state.c b/network/game_state.c index 53bb8af5dbbf..1d39125fd76f 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -46,6 +46,21 @@ static slock_t *g_state_lock = NULL; static ra_game_state_t g_state; static bool g_is_running = false; +#ifdef HAVE_CHEEVOS +/* Logged-in RetroAchievements user (set once on login). */ +typedef struct +{ + char username [128]; + char display_name[128]; + char avatar_url [512]; + uint32_t score; + uint32_t score_softcore; + bool is_logged_in; +} ra_user_state_t; + +static ra_user_state_t g_user_state; +#endif + /* ------------------------------------------------------------------------- * Lifecycle * ---------------------------------------------------------------------- */ @@ -57,6 +72,9 @@ void game_state_init(void) g_state_lock = slock_new(); memset(&g_state, 0, sizeof(g_state)); g_is_running = false; +#ifdef HAVE_CHEEVOS + memset(&g_user_state, 0, sizeof(g_user_state)); +#endif } void game_state_deinit(void) @@ -351,5 +369,96 @@ size_t game_state_achievements_to_json(const rc_client_t *client, buf[pos < buf_size ? pos : buf_size - 1] = '\0'; return pos; } + +/** + * game_state_set_user_from_cheevos: + * + * Copies the logged-in RA user information into the internal user state + * so that game_state_user_to_json() can serialise it at any time. + * Called from rcheevos_client_login_callback() once the async login + * request has succeeded. + */ +void game_state_set_user_from_cheevos(const rc_client_user_t *user) +{ + if (!user || !g_state_lock) + return; + + slock_lock(g_state_lock); + memset(&g_user_state, 0, sizeof(g_user_state)); + + if (!string_is_empty(user->username)) + strlcpy(g_user_state.username, user->username, + sizeof(g_user_state.username)); + + if (!string_is_empty(user->display_name)) + strlcpy(g_user_state.display_name, user->display_name, + sizeof(g_user_state.display_name)); + + if (!string_is_empty(user->avatar_url)) + strlcpy(g_user_state.avatar_url, user->avatar_url, + sizeof(g_user_state.avatar_url)); + + g_user_state.score = user->score; + g_user_state.score_softcore = user->score_softcore; + g_user_state.is_logged_in = true; + + slock_unlock(g_state_lock); +} + +/** + * game_state_user_to_json: + * + * Serialises the current logged-in RA user as a JSON object. Returns + * the number of bytes written or 0 on error. + */ +size_t game_state_user_to_json(char *buf, size_t buf_size) +{ + ra_user_state_t snap; + size_t pos = 0; + int n; + + if (!buf || buf_size < 2) + return 0; + + if (g_state_lock) + { + slock_lock(g_state_lock); + snap = g_user_state; + slock_unlock(g_state_lock); + } + else + { + memset(&snap, 0, sizeof(snap)); + } + + if (!snap.is_logged_in) + { + n = snprintf(buf, buf_size, "{\"type\":\"no_user\"}"); + return (n > 0) ? (size_t)n : 0; + } + + n = snprintf(buf, buf_size, "{\"type\":\"user\""); + if (n <= 0) + return 0; + pos = (size_t)n; + + json_append_field(buf, &pos, buf_size, "username", snap.username); + json_append_field(buf, &pos, buf_size, "display_name", snap.display_name); + json_append_field(buf, &pos, buf_size, "avatar_url", snap.avatar_url); + + n = snprintf(buf + pos, buf_size - pos, + ",\"score\":%u,\"score_softcore\":%u", + (unsigned)snap.score, (unsigned)snap.score_softcore); + if (n > 0) + pos += (size_t)n; + + if (pos + 1 < buf_size) + { + buf[pos++] = '}'; + buf[pos] = '\0'; + } + + return pos; +} #endif diff --git a/network/game_state.h b/network/game_state.h index 65ac6734615c..d3d1e71ff01d 100644 --- a/network/game_state.h +++ b/network/game_state.h @@ -37,6 +37,7 @@ #ifndef RC_CLIENT_H typedef struct rc_client_t rc_client_t; typedef struct rc_client_game_t rc_client_game_t; +typedef struct rc_client_user_t rc_client_user_t; #endif #endif @@ -163,6 +164,34 @@ size_t game_state_to_json(char *buf, size_t buf_size); */ void game_state_update_from_cheevos(const rc_client_game_t *game, const char *game_path); +/** + * game_state_set_user_from_cheevos: + * @user : rc_client_user_t returned by rc_client_get_user_info(). + * + * Stores the logged-in user's display name, score, and avatar URL so + * that game_state_user_to_json() can serialize them. Thread-safe. + */ +void game_state_set_user_from_cheevos(const rc_client_user_t *user); + +/** + * game_state_user_to_json: + * @buf : destination buffer. + * @buf_size : total size of @buf in bytes. + * + * Serialises the logged-in RA user as a JSON object: + * { "type":"user", + * "username":"...", "display_name":"...", + * "score":N, "score_softcore":N, + * "avatar_url":"..." } + * + * When no user is logged in: + * { "type":"no_user" } + * + * Returns the number of bytes written (excluding NUL), or 0 on error. + * Thread-safe. + */ +size_t game_state_user_to_json(char *buf, size_t buf_size); + /** * game_state_achievements_to_json: * @client : the rc_client_t that owns the loaded game. diff --git a/network/ws_server.c b/network/ws_server.c index d1d9bf3cb863..ed63e9aec5f2 100644 --- a/network/ws_server.c +++ b/network/ws_server.c @@ -105,6 +105,7 @@ static slock_t *g_lock = NULL; static bool g_running = false; static bool g_game_broadcast_pending = false; static bool g_ach_broadcast_pending = false; +static bool g_user_broadcast_pending = false; /* Written by the service thread before lws_callback_on_writable_all_protocol, * read inside the WRITEABLE callback — both on the same service thread. @@ -125,6 +126,7 @@ static int g_broadcast_kind = 0; * ---------------------------------------------------------------------- */ #define WS_MSG_GAME_STATE (1 << 0) #define WS_MSG_ACHIEVEMENTS (1 << 1) +#define WS_MSG_USER (1 << 2) typedef struct { int pending_messages; @@ -189,6 +191,29 @@ static void ws_write_achievements(struct lws *wsi) #endif } +/** + * ws_write_user: + * @wsi : the WebSocket connection to write to. + * + * Serialises the logged-in RA user info as JSON and sends it to @wsi. + * Must be called from within the libwebsockets service thread. + */ +static void ws_write_user(struct lws *wsi) +{ +#ifdef HAVE_CHEEVOS + unsigned char buf[LWS_PRE + WS_MSG_MAX_BYTES]; + size_t len; + + len = game_state_user_to_json((char *)(buf + LWS_PRE), WS_MSG_MAX_BYTES); + if (len == 0) + return; + + lws_write(wsi, buf + LWS_PRE, len, LWS_WRITE_TEXT); +#else + (void)wsi; +#endif +} + /* ------------------------------------------------------------------------- * Protocol callback * ---------------------------------------------------------------------- */ @@ -205,9 +230,9 @@ static int callback_retroarch(struct lws *wsi, switch (reason) { case LWS_CALLBACK_ESTABLISHED: - /* New client: queue both messages and request the first write. */ + /* New client: queue user info + game state + achievements and request the first write. */ if (session) - session->pending_messages = WS_MSG_GAME_STATE | WS_MSG_ACHIEVEMENTS; + session->pending_messages = WS_MSG_USER | WS_MSG_GAME_STATE | WS_MSG_ACHIEVEMENTS; fprintf(stderr, "[ws_server] CONNECTED\n"); lws_callback_on_writable(wsi); break; @@ -228,7 +253,13 @@ static int callback_retroarch(struct lws *wsi, } /* Send exactly one message per WRITEABLE invocation. */ - if (session->pending_messages & WS_MSG_GAME_STATE) + if (session->pending_messages & WS_MSG_USER) + { + session->pending_messages &= ~WS_MSG_USER; + fprintf(stderr, "[ws_server] WRITE USER\n"); + ws_write_user(wsi); + } + else if (session->pending_messages & WS_MSG_GAME_STATE) { session->pending_messages &= ~WS_MSG_GAME_STATE; fprintf(stderr, "[ws_server] WRITE GAME\n"); @@ -284,38 +315,36 @@ static void ws_server_thread(void *userdata) bool running; bool game_broadcast; bool ach_broadcast; + bool user_broadcast; slock_lock(g_lock); running = g_running; game_broadcast = g_game_broadcast_pending; ach_broadcast = g_ach_broadcast_pending; + user_broadcast = g_user_broadcast_pending; if (game_broadcast) g_game_broadcast_pending = false; if (ach_broadcast) g_ach_broadcast_pending = false; + if (user_broadcast) + g_user_broadcast_pending = false; slock_unlock(g_lock); if (!running) break; - if (game_broadcast || ach_broadcast) + if (game_broadcast || ach_broadcast || user_broadcast) { - /* Store the broadcast kind so the WRITEABLE callback knows which - * messages to send. Written here on the service thread, read and - * cleared inside the WRITEABLE callback which also runs on the - * service thread — so no races. - * - * We must NOT reset g_broadcast_kind here before lws_service(): - * the WRITEABLE callbacks are fired *inside* lws_service(), so - * they would always see 0 and skip the write. Instead the - * callback itself clears g_broadcast_kind after adopting it into - * the per-session pending_messages bitmask. That way any extra - * unsolicited WRITEABLE callbacks that lws fires while draining - * large payloads still find pending_messages == 0 and - * g_broadcast_kind == 0 and return without doing anything. */ - g_broadcast_kind = game_broadcast - ? (WS_MSG_GAME_STATE | WS_MSG_ACHIEVEMENTS) - : WS_MSG_ACHIEVEMENTS; + /* Compose the message bitmask for this broadcast. */ + int kind = 0; + if (game_broadcast) + kind |= WS_MSG_GAME_STATE | WS_MSG_ACHIEVEMENTS; + if (ach_broadcast) + kind |= WS_MSG_ACHIEVEMENTS; + if (user_broadcast) + kind |= WS_MSG_USER; + + g_broadcast_kind = kind; lws_callback_on_writable_all_protocol(g_lws_ctx, &g_protocols[0]); } @@ -363,9 +392,10 @@ bool ws_server_init(unsigned port) } slock_lock(g_lock); - g_running = true; + g_running = true; g_game_broadcast_pending = false; g_ach_broadcast_pending = false; + g_user_broadcast_pending = false; slock_unlock(g_lock); g_thread = sthread_create(ws_server_thread, NULL); @@ -444,3 +474,15 @@ void ws_server_notify_achievements_changed(void) lws_cancel_service(g_lws_ctx); } +void ws_server_notify_user_changed(void) +{ + if (!g_lws_ctx || !g_lock) + return; + + slock_lock(g_lock); + g_user_broadcast_pending = true; + slock_unlock(g_lock); + + lws_cancel_service(g_lws_ctx); +} + diff --git a/network/ws_server.h b/network/ws_server.h index 78af84d492ec..a52737fd4bcd 100644 --- a/network/ws_server.h +++ b/network/ws_server.h @@ -71,6 +71,16 @@ void ws_server_notify_game_changed(void); */ void ws_server_notify_achievements_changed(void); +/** + * ws_server_notify_user_changed: + * + * Broadcasts the current RA user info (username, display name, score, + * avatar URL) to all connected WebSocket clients. Call this after a + * successful RetroAchievements login. Safe to call from any thread + * while the server is running. + */ +void ws_server_notify_user_changed(void); + #ifdef __cplusplus } From 1581db94d2857fb2ffe9aa4c7fac862a8f83ea50 Mon Sep 17 00:00:00 2001 From: kzryzstof Date: Sat, 21 Mar 2026 21:02:51 -0400 Subject: [PATCH 08/37] Add support for serializing achievement descriptions in game state JSON output --- network/game_state.c | 16 +++++++++------- network/game_state.h | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/network/game_state.c b/network/game_state.c index 1d39125fd76f..141878f92612 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -288,11 +288,12 @@ void game_state_update_from_cheevos(const rc_client_game_t *game, * * Iterates all core achievements for the loaded game and serializes * them as a JSON object. Each entry contains: - * id – numeric achievement ID - * name – achievement title - * points – point value - * status – "unlocked" or "locked" - * badge_url – unlocked badge image URL (omitted when unavailable) + * id – numeric achievement ID + * name – achievement title + * description – achievement description text + * points – point value + * status – "unlocked" or "locked" + * badge_url – unlocked badge image URL (omitted when unavailable) */ size_t game_state_achievements_to_json(const rc_client_t *client, char *buf, size_t buf_size) @@ -345,8 +346,9 @@ size_t game_state_achievements_to_json(const rc_client_t *client, if (n > 0) pos += (size_t)n; - /* name – JSON-escaped via json_append_field */ - json_append_field(buf, &pos, buf_size, "name", ach->title); + /* name and description – JSON-escaped via json_append_field */ + json_append_field(buf, &pos, buf_size, "name", ach->title); + json_append_field(buf, &pos, buf_size, "description", ach->description); /* badge_url – use the unlocked URL when present */ if (!string_is_empty(ach->badge_url)) diff --git a/network/game_state.h b/network/game_state.h index d3d1e71ff01d..6125909dbd01 100644 --- a/network/game_state.h +++ b/network/game_state.h @@ -202,7 +202,7 @@ size_t game_state_user_to_json(char *buf, size_t buf_size); * JSON object of the shape: * { "type":"achievements", * "items": [ - * { "id":1, "name":"...", "points":5, + * { "id":1, "name":"...", "description":"...", "points":5, * "status":"unlocked", * "badge_url":"https://..." }, * ... From 6125d0582a85a10fba70a8f4f08c2ad534374e4a Mon Sep 17 00:00:00 2001 From: kzryzstof Date: Sun, 22 Mar 2026 10:28:54 -0400 Subject: [PATCH 09/37] Filter core achievements by subset in game state JSON serialization --- network/game_state.c | 54 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/network/game_state.c b/network/game_state.c index 141878f92612..122aaa398287 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -299,21 +299,37 @@ size_t game_state_achievements_to_json(const rc_client_t *client, char *buf, size_t buf_size) { rc_client_achievement_list_t *list; - size_t pos = 0; - int n; - uint32_t bi, ai; - bool first = true; + rc_client_subset_list_t *subset_list; + uint32_t base_subset_id = 0; + size_t pos = 0; + int n; + uint32_t bi, ai; + bool first = true; if (!client || !buf || buf_size < 2) return 0; + /* Determine the core subset id deterministically. + * rc_client_create_subset_list always places the core (base) set at + * index 0 — it is prepended in rcheevos internals while addon subsets + * are appended. When num_subsets > 1 rcheevos sets bucket->subset_id + * to the subset's own id, so we need this value to identify base-game + * buckets. When there is only one subset subset_id is always 0, so + * the comparison below still works correctly. */ + subset_list = rc_client_create_subset_list((rc_client_t *)client); + if (subset_list) + { + if (subset_list->num_subsets > 0) + base_subset_id = subset_list->subsets[0]->id; + rc_client_destroy_subset_list(subset_list); + } + /* Open the envelope */ n = snprintf(buf, buf_size, "{\"type\":\"achievements\",\"items\":["); if (n <= 0) return 0; pos = (size_t)n; - /* Enumerate core achievements grouped by lock-state bucket */ list = rc_client_create_achievement_list( (rc_client_t *)client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, @@ -324,14 +340,32 @@ size_t game_state_achievements_to_json(const rc_client_t *client, for (bi = 0; bi < list->num_buckets; bi++) { const rc_client_achievement_bucket_t *bucket = &list->buckets[bi]; + + /* Only LOCKED and UNLOCKED buckets. */ + if (bucket->bucket_type != RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED && + bucket->bucket_type != RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED) + continue; + + /* When multiple subsets are loaded each bucket carries the id of + * the subset it belongs to. Only keep buckets from the core set. + * When there is only one subset, rcheevos sets subset_id=0 for all + * buckets regardless of the real subset id, so accept both 0 and + * the actual base subset id. */ + if (bucket->subset_id != base_subset_id) + continue; + for (ai = 0; ai < bucket->num_achievements; ai++) { const rc_client_achievement_t *ach = bucket->achievements[ai]; - const char *status = - (ach->unlocked != RC_CLIENT_ACHIEVEMENT_UNLOCKED_NONE) + const char *status; + + /* Belt-and-suspenders: only core achievements. */ + if (ach->category != RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE) + continue; + + status = (ach->unlocked != RC_CLIENT_ACHIEVEMENT_UNLOCKED_NONE) ? "unlocked" : "locked"; - /* Separator between items */ if (!first) { if (pos + 1 < buf_size) @@ -339,22 +373,18 @@ size_t game_state_achievements_to_json(const rc_client_t *client, } first = false; - /* id, name, points, status */ n = snprintf(buf + pos, buf_size - pos, "{\"id\":%u,\"points\":%u,\"status\":\"%s\"", (unsigned)ach->id, (unsigned)ach->points, status); if (n > 0) pos += (size_t)n; - /* name and description – JSON-escaped via json_append_field */ json_append_field(buf, &pos, buf_size, "name", ach->title); json_append_field(buf, &pos, buf_size, "description", ach->description); - /* badge_url – use the unlocked URL when present */ if (!string_is_empty(ach->badge_url)) json_append_field(buf, &pos, buf_size, "badge_url", ach->badge_url); - /* Close the achievement object */ if (pos + 1 < buf_size) buf[pos++] = '}'; } From 4926f2e6b7a343f9e333746df85d64be94d2f53c Mon Sep 17 00:00:00 2001 From: kzryzstof Date: Sun, 22 Mar 2026 10:40:39 -0400 Subject: [PATCH 10/37] Filter core achievements by subset in game state JSON serialization --- network/game_state.c | 16 +++++++--------- network/game_state.h | 17 ++++++----------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/network/game_state.c b/network/game_state.c index 122aaa398287..dc1f77c43afb 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -220,11 +220,9 @@ size_t game_state_to_json(char *buf, size_t buf_size) json_append_field(buf, &pos, buf_size, "game_id", snap.game_id); json_append_field(buf, &pos, buf_size, "game_name", snap.game_name); - json_append_field(buf, &pos, buf_size, "game_path", snap.game_path); json_append_field(buf, &pos, buf_size, "console_id", snap.console_id); json_append_field(buf, &pos, buf_size, "console_name", snap.console_name); - json_append_field(buf, &pos, buf_size, "core_name", snap.core_name); - json_append_field(buf, &pos, buf_size, "db_name", snap.db_name); + json_append_field(buf, &pos, buf_size, "cover_url", snap.cover_url); /* Close object */ if (pos + 1 < buf_size) @@ -243,12 +241,12 @@ size_t game_state_to_json(char *buf, size_t buf_size) * Sole entry-point for populating and broadcasting the WebSocket game * state. Called from rcheevos_client_load_game_callback() once the * async RetroAchievements lookup has completed. Builds a fresh - * ra_game_state_t entirely from RA data + the ROM path: + * ra_game_state_t entirely from RA data: * * id → game_id (authoritative numeric RA game ID) * title → game_name (RA-canonical title) * console_id → console_name (human-readable via rc_console_name()) - * game_path → game_path (full filesystem path to the ROM) + * badge_url → cover_url (game cover image URL) */ void game_state_update_from_cheevos(const rc_client_game_t *game, const char *game_path) @@ -267,10 +265,6 @@ void game_state_update_from_cheevos(const rc_client_game_t *game, if (!string_is_empty(game->title)) strlcpy(state.game_name, game->title, sizeof(state.game_name)); - /* Full filesystem path to the ROM */ - if (!string_is_empty(game_path)) - strlcpy(state.game_path, game_path, sizeof(state.game_path)); - /* Human-readable console name */ if (game->console_id != 0) { @@ -279,6 +273,10 @@ void game_state_update_from_cheevos(const rc_client_game_t *game, strlcpy(state.console_name, con, sizeof(state.console_name)); } + /* Game cover/badge URL from RetroAchievements */ + if (!string_is_empty(game->badge_url)) + strlcpy(state.cover_url, game->badge_url, sizeof(state.cover_url)); + game_state_set(&state); ws_server_notify_game_changed(); } diff --git a/network/game_state.h b/network/game_state.h index 6125909dbd01..b2adb02c0c5c 100644 --- a/network/game_state.h +++ b/network/game_state.h @@ -48,11 +48,9 @@ extern "C" { /* Maximum lengths for each field (including NUL terminator). */ #define GAME_STATE_GAME_ID_LEN 64 #define GAME_STATE_GAME_NAME_LEN 512 -#define GAME_STATE_GAME_PATH_LEN 4096 #define GAME_STATE_CONSOLE_ID_LEN 64 #define GAME_STATE_CONSOLE_NAME_LEN 256 -#define GAME_STATE_CORE_NAME_LEN 256 -#define GAME_STATE_DB_NAME_LEN 512 +#define GAME_STATE_COVER_URL_LEN 512 /** * ra_game_state_t: @@ -62,23 +60,20 @@ extern "C" { * game_id – CRC-32 checksum of the ROM as a hex string (may be * empty if unknown). * game_name – Base filename of the ROM without extension. - * game_path – Full filesystem path to the ROM. * console_id – Short system/platform identifier supplied by the * core info database (e.g. "snes", "megadrive"). * console_name – Human-readable platform name (e.g. * "Super Nintendo Entertainment System"). - * core_name – Name of the libretro core that is running the game. - * db_name – Playlist/database name associated with the content. + * cover_url – URL of the game cover/badge image from + * RetroAchievements (may be empty if unavailable). */ typedef struct { char game_id [GAME_STATE_GAME_ID_LEN]; char game_name [GAME_STATE_GAME_NAME_LEN]; - char game_path [GAME_STATE_GAME_PATH_LEN]; char console_id [GAME_STATE_CONSOLE_ID_LEN]; char console_name [GAME_STATE_CONSOLE_NAME_LEN]; - char core_name [GAME_STATE_CORE_NAME_LEN]; - char db_name [GAME_STATE_DB_NAME_LEN]; + char cover_url [GAME_STATE_COVER_URL_LEN]; } ra_game_state_t; /** @@ -139,9 +134,9 @@ bool game_state_get(ra_game_state_t *out); * * When a game is running the object has the shape: * { "type":"game_playing", - * "game_id":"...", "game_name":"...", "game_path":"...", + * "game_id":"...", "game_name":"...", * "console_id":"...", "console_name":"...", - * "core_name":"...", "db_name":"..." } + * "cover_url":"..." } * * When no game is running: * { "type":"no_game" } From 1bda13feb623c813d689832b7fa6c257a157df94 Mon Sep 17 00:00:00 2001 From: kzryzstof Date: Sun, 22 Mar 2026 20:26:36 -0400 Subject: [PATCH 11/37] Add playlist-based cover URL generation in game state updates --- network/game_state.c | 96 +++++++++++++++++++++++++++++++++++++++----- network/game_state.h | 10 ++--- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/network/game_state.c b/network/game_state.c index dc1f77c43afb..8831b5835b5f 100644 --- a/network/game_state.c +++ b/network/game_state.c @@ -28,10 +28,16 @@ #include #include #include +#include + +#include "../playlist.h" #include #include + +#define GAME_STATE_TAG "[game_state] " + #ifdef HAVE_CHEEVOS #include "../deps/rcheevos/include/rc_client.h" #include "../deps/rcheevos/include/rc_consoles.h" @@ -218,11 +224,11 @@ size_t game_state_to_json(char *buf, size_t buf_size) return 0; pos = (size_t)n; - json_append_field(buf, &pos, buf_size, "game_id", snap.game_id); - json_append_field(buf, &pos, buf_size, "game_name", snap.game_name); - json_append_field(buf, &pos, buf_size, "console_id", snap.console_id); - json_append_field(buf, &pos, buf_size, "console_name", snap.console_name); - json_append_field(buf, &pos, buf_size, "cover_url", snap.cover_url); + json_append_field(buf, &pos, buf_size, "game_id", snap.game_id); + json_append_field(buf, &pos, buf_size, "game_name", snap.game_name); + json_append_field(buf, &pos, buf_size, "console_id", snap.console_id); + json_append_field(buf, &pos, buf_size, "console_name", snap.console_name); + json_append_field(buf, &pos, buf_size, "cover_url", snap.cover_url); /* Close object */ if (pos + 1 < buf_size) @@ -241,17 +247,19 @@ size_t game_state_to_json(char *buf, size_t buf_size) * Sole entry-point for populating and broadcasting the WebSocket game * state. Called from rcheevos_client_load_game_callback() once the * async RetroAchievements lookup has completed. Builds a fresh - * ra_game_state_t entirely from RA data: + * ra_game_state_t from RA data: * * id → game_id (authoritative numeric RA game ID) * title → game_name (RA-canonical title) * console_id → console_name (human-readable via rc_console_name()) - * badge_url → cover_url (game cover image URL) + * playlist entry → cover_url (libretro thumbnails boxart URL from label + db_name) */ void game_state_update_from_cheevos(const rc_client_game_t *game, const char *game_path) { - ra_game_state_t state; + ra_game_state_t state; + const struct playlist_entry *entry = NULL; + const char *db_src = NULL; if (!game || game->id == 0) return; @@ -273,9 +281,75 @@ void game_state_update_from_cheevos(const rc_client_game_t *game, strlcpy(state.console_name, con, sizeof(state.console_name)); } - /* Game cover/badge URL from RetroAchievements */ - if (!string_is_empty(game->badge_url)) - strlcpy(state.cover_url, game->badge_url, sizeof(state.cover_url)); + /* Look up the playlist entry for this ROM to get the label and + * db_name used by the libretro thumbnail server. */ + if (!string_is_empty(game_path)) + { + playlist_t *pl = playlist_get_cached(); + fprintf(stderr, GAME_STATE_TAG "game_path: \"%s\"\n", game_path); + fprintf(stderr, GAME_STATE_TAG "cached playlist: %s\n", pl ? "found" : "NULL"); + if (pl) + { + playlist_get_index_by_path(pl, game_path, &entry); + fprintf(stderr, GAME_STATE_TAG "playlist entry: %s\n", entry ? "found" : "not found"); + if (entry) + { + fprintf(stderr, GAME_STATE_TAG " entry->label: \"%s\"\n", + entry->label ? entry->label : "(null)"); + fprintf(stderr, GAME_STATE_TAG " entry->db_name: \"%s\"\n", + entry->db_name ? entry->db_name : "(null)"); + } + } + } + else + fprintf(stderr, GAME_STATE_TAG "game_path: (empty)\n"); + + /* System folder = db_name without ".lpl" extension. + * Falls back to the RA console name when no playlist entry exists. */ + if (entry && !string_is_empty(entry->db_name)) + db_src = entry->db_name; + else + db_src = state.console_name; + + fprintf(stderr, GAME_STATE_TAG "db_src: \"%s\"\n", db_src ? db_src : "(null)"); + + /* Build the cover URL from the playlist label and db_name: + * https://thumbnails.libretro.com//Named_Boxarts/