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/