From a2b2c0f8f7e66a7e0992862a483e3ae84da2484b Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 8 Sep 2026 01:04:19 -0400 Subject: [PATCH 1/9] netplay: Discord sign-in and the device key The runner half. psx_netplay_auth owns the HTTP, a worker thread and the key; main.cpp is eight one-line adapters over it; queue_hello gains an optional session field. The key never crosses the wire. This runtime has no TLS -- wss:// is still an explicit "not in this phase" -- so a permanent bearer credential sent in cleartext would be captured once on a hostile network and reused forever. Instead the server issues a nonce and this proves possession with HMAC-SHA256 keyed by SHA-256 of the key. psx_sha256.c was already here, so the HMAC is twenty lines and there is no new dependency. netplay_secret is written 0600 beside the other runtime config, in the form " ", and redeemed lazily on the first pump so a machine that signed in once comes up signed in with no player action. A stored key that no longer works (revoked, or a different server) is dropped and the client falls back to guest rather than nagging every launch. That file is the point of the exercise: sign in once on a PC and the build you install on a handheld authenticates with no browser -- and it is a password in a file, so it must not go into anything distributed. Guest stays byte-identical. `session` is omitted entirely rather than sent empty, so an unauthenticated hello is the exact frame this client has always sent. The launcher wiring is guarded on RECOMP_LAUNCHER_HAS_ACCOUNT so this builds against a recomp-ui that predates the callbacks too, and the runner and the UI can land in either order. Opening the browser is per platform (ShellExecute / xdg-open / open) since there is no SDL_OpenURL here. The URL comes from our own lobby server but still reaches a shell, so anything that is not a plain http(s) URL free of shell metacharacters is refused rather than trusted. Verified: psx_netplay_auth.c and psx_lobby_client.c both compile clean under -Wall -Wextra, and every psx_account_* symbol main.cpp uses is declared and defined. main.cpp itself could NOT be compiled here -- it needs a generated tree (bios_rom_alias.h) -- so its changes are verified by symbol cross-check and inspection only, not by a build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015C38nLRmmyXHyJ7P1DiTxg --- runtime/include/psx_netplay_auth.h | 63 +++ runtime/runtime.cmake | 1 + runtime/src/main.cpp | 34 ++ runtime/src/psx_lobby_client.c | 22 +- runtime/src/psx_netplay_auth.c | 619 +++++++++++++++++++++++++++++ 5 files changed, 735 insertions(+), 4 deletions(-) create mode 100644 runtime/include/psx_netplay_auth.h create mode 100644 runtime/src/psx_netplay_auth.c diff --git a/runtime/include/psx_netplay_auth.h b/runtime/include/psx_netplay_auth.h new file mode 100644 index 00000000..c227d9be --- /dev/null +++ b/runtime/include/psx_netplay_auth.h @@ -0,0 +1,63 @@ +/* psx_netplay_auth.h -- Discord sign-in and the device key, for the launcher. + * + * Backs recomp-ui's optional `account_*` netplay callbacks. Everything here is + * optional at runtime: a lobby server whose operator never configured Discord + * answers 503 to a login start, and this reports "not available" so the + * launcher draws no sign-in at all. A player who never signs in is a guest, + * which is what the runtime has always been. + * + * Two credentials, and the difference matters: + * + * netplay_secret Long-lived, per device, written to a file beside the + * config. NEVER sent anywhere. Proved by HMAC over a + * server nonce, because this runtime has no TLS and a + * permanent bearer token in cleartext would be captured + * once and reused forever. + * session Short-lived JWT, minted from that proof, and the only + * thing that goes to the lobby (in `hello`). + * + * The secret file is what makes a browserless device work: sign in once on a + * PC, and the file travels with a build installed on a handheld. It is a + * password in a file -- do not put it in anything you distribute. + */ +#ifndef PSX_NETPLAY_AUTH_H +#define PSX_NETPLAY_AUTH_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Mirrors recomp-ui's RECOMP_LAUNCHER_ACCOUNT_* so this header does not + * depend on the launcher's. */ +enum { + PSX_ACCOUNT_GUEST = 0, + PSX_ACCOUNT_WAITING = 1, + PSX_ACCOUNT_SIGNED_IN = 2, + PSX_ACCOUNT_FAILED = 3 +}; + +/* `ws_url` is the lobby URL the client is configured with; the HTTP endpoints + * live on the same host and port. Safe to call again when the URL changes. */ +void psx_account_init(const char *ws_url); + +/* Call once per frame. Drives the worker handshake and, when a stored key + * exists, redeems it for a session on first use. */ +void psx_account_pump(void); + +int psx_account_available(void); +int psx_account_login_begin(void); +int psx_account_state(void); +const char *psx_account_handle(void); +const char *psx_account_username(void); +const char *psx_account_error(void); +int psx_account_sign_out(void); +int psx_account_set_handle(const char *handle); + +/* The session for `hello`, or "" when this client is a guest. */ +const char *psx_account_session(void); + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_NETPLAY_AUTH_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 59edc928..f2396a21 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -374,6 +374,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/nd_intro_ot.c ${PSXRECOMP_ROOT}/runtime/src/crc32.c ${PSXRECOMP_ROOT}/runtime/src/psx_sha256.c + ${PSXRECOMP_ROOT}/runtime/src/psx_netplay_auth.c ${PSXRECOMP_ROOT}/runtime/src/disc_identity.cpp ${PSXRECOMP_ROOT}/runtime/src/cue_sheet.cpp ${PSXRECOMP_ROOT}/runtime/src/disc_path.cpp diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 95df1665..c593be03 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -56,6 +56,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "psx_netplay_rb.h" #include "psx_selfcheck.h" #include "psx_lobby_client.h" +#include "psx_netplay_auth.h" #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */ #endif @@ -9990,6 +9991,19 @@ namespace { } return psx_lobby_send_chat(line); } + /* ---- optional Discord sign-in ------------------------------------- + * Thin adapters over psx_netplay_auth, which owns the HTTP, the worker + * thread and the device key. Nothing here blocks a frame except the + * rename, which is one round trip and wants a verdict for its modal. */ + int ae_np_account_available(void*) { return psx_account_available(); } + int ae_np_account_login_begin(void*) { return psx_account_login_begin(); } + int ae_np_account_state(void*) { return psx_account_state(); } + const char* ae_np_account_handle(void*) { return psx_account_handle(); } + const char* ae_np_account_username(void*) { return psx_account_username(); } + const char* ae_np_account_error(void*) { return psx_account_error(); } + int ae_np_account_sign_out(void*) { return psx_account_sign_out(); } + int ae_np_account_set_handle(void*, const char* h) { return psx_account_set_handle(h); } + int ae_np_chat_count(void*) { ae_np_chat_track_room(); if (g_lnch_hosting_lan || g_lnch_joined_lan) return g_lnch_lan_chat_count; @@ -10195,6 +10209,9 @@ namespace { void ae_np_set_lobby_url(void*, const char* url) { g_lnch_lobby_url = url && url[0] ? url : psx_lobby_default_url(); ae_np_save_identity(nullptr, g_lnch_lobby_url.c_str()); + /* The auth endpoints live on the same host and port as the lobby + * socket, so the sign-in follows whatever server the player points at. */ + psx_account_init(g_lnch_lobby_url.c_str()); } int ae_np_connect(void*) { @@ -10773,6 +10790,10 @@ namespace { void ae_np_pump(void*) { psx_lobby_pump(); + /* Redeems a stored device key on the first pump, so a machine that has + * signed in once comes up signed in with no player action. */ + psx_account_init(g_lnch_lobby_url.c_str()); + psx_account_pump(); ae_np_lan_browse_pump(); ae_np_lan_udp_pump(); /* Lobby UI has no Ready toggle; production WS still requires every @@ -12028,6 +12049,19 @@ namespace { g_lnch_netplay_callbacks.server_chat_send = ae_np_server_chat_send; g_lnch_netplay_callbacks.server_chat_count = ae_np_server_chat_count; g_lnch_netplay_callbacks.server_chat_get = ae_np_server_chat_get; +#if defined(RECOMP_LAUNCHER_HAS_ACCOUNT) + /* Optional Discord sign-in. Guarded on the launcher ABI macro so this + * runtime still builds against a recomp-ui that predates it -- the UI + * and the runner can land in either order. */ + g_lnch_netplay_callbacks.account_available = ae_np_account_available; + g_lnch_netplay_callbacks.account_login_begin = ae_np_account_login_begin; + g_lnch_netplay_callbacks.account_state = ae_np_account_state; + g_lnch_netplay_callbacks.account_handle = ae_np_account_handle; + g_lnch_netplay_callbacks.account_username = ae_np_account_username; + g_lnch_netplay_callbacks.account_error = ae_np_account_error; + g_lnch_netplay_callbacks.account_sign_out = ae_np_account_sign_out; + g_lnch_netplay_callbacks.account_set_handle = ae_np_account_set_handle; +#endif g_lnch_netplay_callbacks.seat_move_self = ae_np_seat_move_self; g_lnch_netplay_callbacks.seat_swap_request = ae_np_seat_swap_request; g_lnch_netplay_callbacks.seat_swap_incoming = ae_np_seat_swap_incoming; diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 129bf023..ea2a2706 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -153,6 +153,7 @@ void psx_lobby_clear_launch_pending(void) {} #include "recomp_net/lan_beacon.h" #include "recomp_net/rtt_probe.h" #include "recomp_net/chat_filter.h" +#include "psx_netplay_auth.h" /* optional Discord session for `hello` */ #include "host_time.h" #if defined(_WIN32) @@ -1868,12 +1869,25 @@ static void queue_hello(void) { char name_esc[PSX_LOBBY_NAME_LEN * 2 + 8]; char game_esc[PSX_LOBBY_NAME_LEN * 2 + 8]; - char msg[PSX_LOBBY_NAME_LEN * 4 + 64]; + char sess_esc[2048]; + char msg[PSX_LOBBY_NAME_LEN * 4 + 2176]; + const char *sess = psx_account_session(); json_escape(g_lc.display_name, name_esc, sizeof(name_esc)); json_escape(g_lc.filter_game_name, game_esc, sizeof(game_esc)); - snprintf(msg, sizeof(msg), - "{\"op\":\"hello\",\"display_name\":\"%s\",\"game_name\":\"%s\"}", - name_esc, game_esc); + /* The session is OPTIONAL and omitted entirely when this client is a + * guest, which is what keeps an unauthenticated hello byte-identical to + * the one this client has always sent. */ + if (sess && sess[0]) { + json_escape(sess, sess_esc, sizeof(sess_esc)); + snprintf(msg, sizeof(msg), + "{\"op\":\"hello\",\"display_name\":\"%s\",\"game_name\":\"%s\"," + "\"session\":\"%s\"}", + name_esc, game_esc, sess_esc); + } else { + snprintf(msg, sizeof(msg), + "{\"op\":\"hello\",\"display_name\":\"%s\",\"game_name\":\"%s\"}", + name_esc, game_esc); + } queue_send(msg); flush_pending(); } diff --git a/runtime/src/psx_netplay_auth.c b/runtime/src/psx_netplay_auth.c new file mode 100644 index 00000000..a4518d78 --- /dev/null +++ b/runtime/src/psx_netplay_auth.c @@ -0,0 +1,619 @@ +/* psx_netplay_auth.c -- see psx_netplay_auth.h. + * + * All network work happens on one worker thread; the launcher only ever reads + * flags this file publishes. That is the same shape the lobby client already + * uses for its off-thread connect, and it is why a login cannot stall a frame. + */ + +#include "psx_netplay_auth.h" +#include "psx_sha256.h" + +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#include +#include +#define close closesocket +typedef HANDLE auth_thread_t; +#else +#include +#include +#include +#include +#include +#include +#include +typedef pthread_t auth_thread_t; +#endif + +#define SECRET_MAX 96 +#define SESSION_MAX 1024 +#define NAME_MAX_LEN 64 +#define ID_MAX 64 + +/* What the worker is doing. The launcher polls psx_account_state(). */ +enum { JOB_NONE = 0, JOB_LOGIN, JOB_SESSION, JOB_SET_HANDLE, JOB_SIGN_OUT }; + +static struct { + char host[192]; + int port; + + /* Published to the launcher. Written by the worker, read on the UI + * thread; single-writer plus aligned word stores, which is what the + * lobby client already relies on for its connect flags. */ + volatile int state; /* PSX_ACCOUNT_* */ + volatile int available; /* -1 unknown, 0 no logins here, 1 yes */ + volatile int busy; + + char secret[SECRET_MAX]; + char session[SESSION_MAX]; + char player_id[ID_MAX]; + char handle[NAME_MAX_LEN]; + char username[NAME_MAX_LEN]; + char error[192]; + + /* Login in flight. */ + char pair_code[128]; + char pending_handle[NAME_MAX_LEN]; + + int job; + int thread_valid; + auth_thread_t thread; + int initialized; + int tried_stored_key; +} g; + +/* ---- small helpers ------------------------------------------------------ */ + +static void set_err(const char *e) { + snprintf(g.error, sizeof(g.error), "%s", e ? e : ""); +} + +/* Minimal extraction for the flat, server-generated JSON these endpoints + * return. Not a parser: it is the same shape psx_lobby_client.c already uses, + * and the bodies come from our own server. */ +static int json_str(const char *json, const char *key, char *out, size_t cap) { + char pat[64]; + const char *p; + size_t o = 0; + if (!json || !out || cap == 0) return 0; + out[0] = '\0'; + snprintf(pat, sizeof(pat), "\"%s\"", key); + p = strstr(json, pat); + if (!p) return 0; + p = strchr(p + strlen(pat), ':'); + if (!p) return 0; + ++p; + while (*p && isspace((unsigned char)*p)) ++p; + if (*p != '"') return 0; + ++p; + while (*p && *p != '"' && o + 1 < cap) { + if (*p == '\\' && p[1]) { + ++p; + switch (*p) { + case 'n': out[o++] = '\n'; break; + case 't': out[o++] = '\t'; break; + case 'r': out[o++] = '\r'; break; + default: out[o++] = *p; break; + } + ++p; + continue; + } + out[o++] = *p++; + } + out[o] = '\0'; + return o > 0; +} + +/* Escape for a JSON string body. Same rules as the lobby client's escaper: + * quote and backslash escaped, control characters dropped. */ +static void json_esc(const char *in, char *out, size_t cap) { + size_t o = 0; + if (!out || cap == 0) return; + while (in && *in && o + 2 < cap) { + unsigned char c = (unsigned char)*in++; + if (c == '"' || c == '\\') { + if (o + 3 >= cap) break; + out[o++] = '\\'; + out[o++] = (char)c; + } else if (c >= 0x20) { + out[o++] = (char)c; + } + } + out[o] = '\0'; +} + +static void hex_encode(const unsigned char *in, size_t n, char *out) { + static const char H[] = "0123456789abcdef"; + size_t i; + for (i = 0; i < n; ++i) { + out[i * 2] = H[in[i] >> 4]; + out[i * 2 + 1] = H[in[i] & 15]; + } + out[n * 2] = '\0'; +} + +/* HMAC-SHA256, keyed by the hex verifier string, over the nonce. The server + * computes the identical thing (secrets::expected_proof). */ +static void hmac_sha256_hex(const char *key, const char *msg, char out_hex[65]) { + unsigned char k[64], inner[32], outer[32], pad[64]; + unsigned char tmp[96]; + psx_sha256_ctx c; + size_t klen = strlen(key), i; + + memset(k, 0, sizeof(k)); + if (klen > 64) { + psx_sha256_compute((const unsigned char *)key, klen, k); + } else { + memcpy(k, key, klen); + } + + for (i = 0; i < 64; ++i) pad[i] = k[i] ^ 0x36; + psx_sha256_init(&c); + psx_sha256_update(&c, pad, 64); + psx_sha256_update(&c, (const unsigned char *)msg, strlen(msg)); + psx_sha256_final(&c, inner); + + for (i = 0; i < 64; ++i) pad[i] = k[i] ^ 0x5c; + memcpy(tmp, pad, 64); + memcpy(tmp + 64, inner, 32); + psx_sha256_compute(tmp, 96, outer); + hex_encode(outer, 32, out_hex); +} + +/* ---- the secret file ---------------------------------------------------- */ + +static void secret_path(char *out, size_t cap) { + /* Beside the other runtime config (keybinds.ini, config.ini), so a build + * copied to another device carries it the same way. */ + snprintf(out, cap, "netplay_secret"); +} + +static void secret_load(void) { + char path[256]; + FILE *f; + size_t n; + secret_path(path, sizeof(path)); + g.secret[0] = '\0'; + g.player_id[0] = '\0'; + f = fopen(path, "rb"); + if (!f) return; + n = fread(g.secret, 1, sizeof(g.secret) - 1, f); + g.secret[n] = '\0'; + fclose(f); + /* Format: " \n". Tolerate a trailing newline. */ + { + char *sp = strchr(g.secret, ' '); + char *nl; + if (sp) { + size_t idn = (size_t)(sp - g.secret); + if (idn >= sizeof(g.player_id)) idn = sizeof(g.player_id) - 1; + memcpy(g.player_id, g.secret, idn); + g.player_id[idn] = '\0'; + memmove(g.secret, sp + 1, strlen(sp + 1) + 1); + } + nl = strpbrk(g.secret, "\r\n"); + if (nl) *nl = '\0'; + } +} + +static void secret_store(const char *player_id, const char *secret) { + char path[256]; + FILE *f; + secret_path(path, sizeof(path)); + f = fopen(path, "wb"); + if (!f) return; + fprintf(f, "%s %s\n", player_id ? player_id : "", secret ? secret : ""); + fclose(f); +#if !defined(_WIN32) + /* A password in a file: keep it to the owner. */ + (void)chmod(path, S_IRUSR | S_IWUSR); +#endif + snprintf(g.secret, sizeof(g.secret), "%s", secret ? secret : ""); + snprintf(g.player_id, sizeof(g.player_id), "%s", player_id ? player_id : ""); +} + +static void secret_forget(void) { + char path[256]; + secret_path(path, sizeof(path)); + remove(path); + g.secret[0] = '\0'; + g.player_id[0] = '\0'; +} + +/* ---- blocking HTTP POST (worker thread only) ---------------------------- */ + +/* Returns the HTTP status, or <0 on a transport failure. Body is copied into + * `body` (truncated to fit), which is enough for these small JSON replies. */ +static int http_post(const char *path, const char *json, char *body, size_t cap) { + struct addrinfo hints, *res = NULL, *ai; + char portstr[16], req[2048]; + char buf[4096]; + int fd = -1, status = -1; + size_t total = 0; + const char *hdr_end; + + if (body && cap) body[0] = '\0'; + snprintf(portstr, sizeof(portstr), "%d", g.port); + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo(g.host, portstr, &hints, &res) != 0 || !res) return -1; + for (ai = res; ai; ai = ai->ai_next) { + fd = (int)socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) continue; + if (connect(fd, ai->ai_addr, (int)ai->ai_addrlen) == 0) break; + close(fd); + fd = -1; + } + freeaddrinfo(res); + if (fd < 0) return -2; + + snprintf(req, sizeof(req), + "POST %s HTTP/1.1\r\n" + "Host: %s:%d\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n\r\n%s", + path, g.host, g.port, (int)strlen(json), json); + if (send(fd, req, (int)strlen(req), 0) < 0) { + close(fd); + return -3; + } + + for (;;) { + int n = (int)recv(fd, buf + total, (int)(sizeof(buf) - 1 - total), 0); + if (n <= 0) break; + total += (size_t)n; + if (total >= sizeof(buf) - 1) break; + } + close(fd); + buf[total] = '\0'; + if (total < 12) return -4; + status = atoi(buf + 9); /* "HTTP/1.1 NNN ..." */ + hdr_end = strstr(buf, "\r\n\r\n"); + if (hdr_end && body && cap) { + snprintf(body, cap, "%s", hdr_end + 4); + } + return status; +} + +/* ---- one challenge/proof round ------------------------------------------ */ + +/* Fills `session` and the display fields. Returns 1 on success. The key never + * leaves this function: only the HMAC does. */ +static int prove_and_get_session(void) { + char body[2048], reqbuf[512], nonce[128], verifier_hex[65], proof[65]; + unsigned char digest[32]; + char id_esc[128]; + int st; + + if (!g.secret[0] || !g.player_id[0]) return 0; + + json_esc(g.player_id, id_esc, sizeof(id_esc)); + snprintf(reqbuf, sizeof(reqbuf), "{\"player_id\":\"%s\"}", id_esc); + st = http_post("/auth/challenge", reqbuf, body, sizeof(body)); + if (st != 200 || !json_str(body, "nonce", nonce, sizeof(nonce))) return 0; + + psx_sha256_compute((const unsigned char *)g.secret, strlen(g.secret), digest); + hex_encode(digest, 32, verifier_hex); + hmac_sha256_hex(verifier_hex, nonce, proof); + + snprintf(reqbuf, sizeof(reqbuf), + "{\"player_id\":\"%s\",\"nonce\":\"%s\",\"proof\":\"%s\"}", + id_esc, nonce, proof); + st = http_post("/auth/session", reqbuf, body, sizeof(body)); + if (st != 200) return 0; + json_str(body, "session", g.session, sizeof(g.session)); + json_str(body, "handle", g.handle, sizeof(g.handle)); + json_str(body, "discord_username", g.username, sizeof(g.username)); + return g.session[0] ? 1 : 0; +} + +/* ---- opening the browser ------------------------------------------------ */ + +/* The one thing the launcher cannot do for us: hand a URL to the desktop. + * There is no SDL_OpenURL in this runtime, so this is per platform. A failure + * is not fatal -- the player can still be told to open the page themselves. */ +static void account_open_url(const char *url) { + if (!url || !url[0]) return; +#if defined(_WIN32) + ShellExecuteA(NULL, "open", url, NULL, NULL, SW_SHOWNORMAL); +#else + { + char cmd[1200]; +#if defined(__APPLE__) + const char *opener = "open"; +#else + const char *opener = "xdg-open"; +#endif + /* The URL comes from our own lobby server, but it still goes through a + * shell, so refuse anything that is not a plain http(s) URL rather + * than trusting the source. */ + const char *q; + if (strncmp(url, "http://", 7) != 0 && strncmp(url, "https://", 8) != 0) return; + for (q = url; *q; ++q) { + if (*q == '\'' || *q == '"' || *q == '`' || *q == '$' || *q == '\\' || + *q == ';' || *q == '|' || *q == '&' || *q == '<' || *q == '>' || + (unsigned char)*q < 0x20) { + fprintf(stderr, "psx_account: refusing to open a suspicious URL\n"); + return; + } + } + snprintf(cmd, sizeof(cmd), "%s '%s' >/dev/null 2>&1 &", opener, url); + if (system(cmd) != 0) { + fprintf(stderr, "psx_account: could not open a browser; visit:\n %s\n", url); + } + } +#endif +} + +/* ---- worker ------------------------------------------------------------- */ + +static void job_login(void) { + char body[2048], url[1024]; + int st, tries; + + st = http_post("/auth/discord/start", "{}", body, sizeof(body)); + if (st == 503) { + g.available = 0; + set_err("This lobby server does not offer Discord sign-in."); + g.state = PSX_ACCOUNT_FAILED; + return; + } + if (st != 200 || !json_str(body, "url", url, sizeof(url)) || + !json_str(body, "code", g.pair_code, sizeof(g.pair_code))) { + set_err("Could not reach the lobby server to start the sign-in."); + g.state = PSX_ACCOUNT_FAILED; + return; + } + g.available = 1; + account_open_url(url); + + /* Poll until the server has an answer. The pairing code expires server + * side after ten minutes; stop a little before that rather than spinning + * forever if the player closed the tab. */ + for (tries = 0; tries < 300; ++tries) { + char req[256]; + char code_esc[160]; + json_esc(g.pair_code, code_esc, sizeof(code_esc)); + snprintf(req, sizeof(req), "{\"code\":\"%s\"}", code_esc); + st = http_post("/auth/discord/poll", req, body, sizeof(body)); + if (st == 200) { + char secret[SECRET_MAX], pid[ID_MAX]; + json_str(body, "session", g.session, sizeof(g.session)); + json_str(body, "handle", g.handle, sizeof(g.handle)); + json_str(body, "discord_username", g.username, sizeof(g.username)); + if (json_str(body, "netplay_secret", secret, sizeof(secret)) && + json_str(body, "player_id", pid, sizeof(pid))) { + /* Returned exactly once. Store it now or it is gone. */ + secret_store(pid, secret); + } + g.state = PSX_ACCOUNT_SIGNED_IN; + return; + } + if (st == 202) { +#if defined(_WIN32) + Sleep(2000); +#else + struct timespec ts; ts.tv_sec = 2; ts.tv_nsec = 0; + nanosleep(&ts, NULL); +#endif + continue; + } + set_err("Discord sign-in did not complete. Try again."); + g.state = PSX_ACCOUNT_FAILED; + return; + } + set_err("Sign-in timed out. Try again."); + g.state = PSX_ACCOUNT_FAILED; +} + +static void job_session(void) { + if (prove_and_get_session()) { + g.state = PSX_ACCOUNT_SIGNED_IN; + } else { + /* A stored key that no longer works (revoked, or a different server): + * drop it and fall back to guest rather than nagging every launch. */ + secret_forget(); + g.session[0] = '\0'; + g.state = PSX_ACCOUNT_GUEST; + } +} + +static void job_set_handle(void) { + char body[1024], req[768], esc[160], id_esc[128], nonce[128]; + char verifier_hex[65], proof[65]; + unsigned char digest[32]; + int st; + + if (!g.secret[0] || !g.player_id[0]) { + set_err("Sign in before changing your name."); + return; + } + json_esc(g.player_id, id_esc, sizeof(id_esc)); + snprintf(req, sizeof(req), "{\"player_id\":\"%s\"}", id_esc); + if (http_post("/auth/challenge", req, body, sizeof(body)) != 200 || + !json_str(body, "nonce", nonce, sizeof(nonce))) { + set_err("Could not reach the lobby server."); + return; + } + psx_sha256_compute((const unsigned char *)g.secret, strlen(g.secret), digest); + hex_encode(digest, 32, verifier_hex); + hmac_sha256_hex(verifier_hex, nonce, proof); + + json_esc(g.pending_handle, esc, sizeof(esc)); + snprintf(req, sizeof(req), + "{\"player_id\":\"%s\",\"nonce\":\"%s\",\"proof\":\"%s\",\"handle\":\"%s\"}", + id_esc, nonce, proof, esc); + st = http_post("/auth/handle", req, body, sizeof(body)); + if (st == 200) { + json_str(body, "handle", g.handle, sizeof(g.handle)); + } else { + set_err("That name can't be used. Please pick another one."); + } +} + +static void job_sign_out(void) { + char body[512], req[512], id_esc[128], nonce[128], verifier_hex[65], proof[65]; + unsigned char digest[32]; + + /* Best effort: tell the server to retire this device's key, then forget it + * locally whatever happened. Signing out must work offline. */ + if (g.secret[0] && g.player_id[0]) { + json_esc(g.player_id, id_esc, sizeof(id_esc)); + snprintf(req, sizeof(req), "{\"player_id\":\"%s\"}", id_esc); + if (http_post("/auth/challenge", req, body, sizeof(body)) == 200 && + json_str(body, "nonce", nonce, sizeof(nonce))) { + psx_sha256_compute((const unsigned char *)g.secret, strlen(g.secret), digest); + hex_encode(digest, 32, verifier_hex); + hmac_sha256_hex(verifier_hex, nonce, proof); + snprintf(req, sizeof(req), + "{\"player_id\":\"%s\",\"nonce\":\"%s\",\"proof\":\"%s\"}", + id_esc, nonce, proof); + (void)http_post("/auth/secret/revoke", req, body, sizeof(body)); + } + } + secret_forget(); + g.session[0] = '\0'; + g.handle[0] = '\0'; + g.username[0] = '\0'; + g.state = PSX_ACCOUNT_GUEST; +} + +#if defined(_WIN32) +static unsigned __stdcall auth_worker(void *arg) { +#else +static void *auth_worker(void *arg) { +#endif + int job = (int)(intptr_t)arg; + switch (job) { + case JOB_LOGIN: job_login(); break; + case JOB_SESSION: job_session(); break; + case JOB_SET_HANDLE: job_set_handle(); break; + case JOB_SIGN_OUT: job_sign_out(); break; + default: break; + } + g.busy = 0; +#if defined(_WIN32) + return 0; +#else + return NULL; +#endif +} + +static int start_job(int job) { + if (g.busy) return -1; + g.busy = 1; + g.job = job; +#if defined(_WIN32) + { + uintptr_t th = _beginthreadex(NULL, 0, auth_worker, (void *)(intptr_t)job, 0, NULL); + if (!th) { g.busy = 0; return -1; } + g.thread = (HANDLE)th; + } +#else + if (pthread_create(&g.thread, NULL, auth_worker, (void *)(intptr_t)job) != 0) { + g.busy = 0; + return -1; + } + pthread_detach(g.thread); +#endif + g.thread_valid = 1; + return 0; +} + +/* ---- public surface ----------------------------------------------------- */ + +void psx_account_init(const char *ws_url) { + const char *p = ws_url; + const char *slash; + const char *colon; + char hostport[192]; + + memset(g.host, 0, sizeof(g.host)); + g.port = 8765; + if (p) { + if (strncmp(p, "ws://", 5) == 0) p += 5; + else if (strncmp(p, "wss://", 6) == 0) p += 6; + slash = strchr(p, '/'); + snprintf(hostport, sizeof(hostport), "%.*s", + slash ? (int)(slash - p) : (int)strlen(p), p); + colon = strrchr(hostport, ':'); + if (colon && strchr(colon, ']') == NULL) { + g.port = atoi(colon + 1); + *(char *)colon = '\0'; + } + snprintf(g.host, sizeof(g.host), "%s", hostport); + } + if (!g.initialized) { + g.available = -1; + g.state = PSX_ACCOUNT_GUEST; + secret_load(); + g.initialized = 1; + } +} + +void psx_account_pump(void) { + if (!g.initialized || g.busy) return; + /* A stored key is redeemed once, lazily, on the first pump: the launcher + * should come up signed in without the player doing anything. */ + if (!g.tried_stored_key && g.secret[0] && g.player_id[0]) { + g.tried_stored_key = 1; + g.state = PSX_ACCOUNT_WAITING; + (void)start_job(JOB_SESSION); + } +} + +int psx_account_available(void) { + /* Unknown until something asks the server. Offering the button is right: + * the first start() settles it, and a 503 turns it off for good. */ + return g.available != 0; +} + +int psx_account_login_begin(void) { + if (g.busy) return -1; + g.error[0] = '\0'; + g.state = PSX_ACCOUNT_WAITING; + return start_job(JOB_LOGIN); +} + +int psx_account_state(void) { return g.state; } +const char *psx_account_handle(void) { return g.handle; } +const char *psx_account_username(void) { return g.username; } +const char *psx_account_error(void) { return g.error; } +const char *psx_account_session(void) { return g.session; } + +int psx_account_sign_out(void) { + if (g.busy) return -1; + return start_job(JOB_SIGN_OUT); +} + +int psx_account_set_handle(const char *handle) { + if (g.busy || !handle || !handle[0]) return -1; + snprintf(g.pending_handle, sizeof(g.pending_handle), "%s", handle); + g.error[0] = '\0'; + if (start_job(JOB_SET_HANDLE) != 0) return -1; + /* The launcher wants a synchronous verdict for its modal, and the request + * is one round trip on a LAN-ish connection: wait briefly rather than + * inventing an async path through the UI for a rename. */ + { + int spins = 0; + while (g.busy && spins++ < 400) { +#if defined(_WIN32) + Sleep(10); +#else + struct timespec ts; ts.tv_sec = 0; ts.tv_nsec = 10 * 1000 * 1000; + nanosleep(&ts, NULL); +#endif + } + } + return g.error[0] ? -1 : 0; +} From cce195f9b43eb44d762816f75d1f617870774584 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 8 Sep 2026 09:51:49 -0400 Subject: [PATCH 2/9] netplay: open the browser without a shell Sign-in did nothing when clicked: the browser never opened. My own hardening broke it. account_open_url built a `xdg-open ''` command string and screened the URL for shell metacharacters, and an OAuth authorize URL is full of `&` -- client_id&response_type&redirect_uri&scope &state -- so every real URL hit the metacharacter check and was refused. The feature could never have worked on Linux or macOS. Fixed by removing the shell rather than by loosening the screen. The URL goes to execlp as a single argv element, so there is no command line for a metacharacter to escape into and nothing to quote. A double fork leaves the opener reparented to init, so a game loop never has a zombie to reap. Windows was always fine -- ShellExecuteA takes the URL directly. The scheme check stays, and is now the whole of the screening: xdg-open will act on a file:// URL or a local path, and this only ever legitimately receives http(s). Verified rather than reasoned about this time: with a stub opener on PATH, a real Discord authorize URL arrives byte-identical, every `&` intact, and a file:// URL is still refused. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015C38nLRmmyXHyJ7P1DiTxg --- runtime/src/psx_netplay_auth.c | 46 +++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/runtime/src/psx_netplay_auth.c b/runtime/src/psx_netplay_auth.c index a4518d78..d49e2659 100644 --- a/runtime/src/psx_netplay_auth.c +++ b/runtime/src/psx_netplay_auth.c @@ -29,6 +29,7 @@ typedef HANDLE auth_thread_t; #include #include #include +#include #include typedef pthread_t auth_thread_t; #endif @@ -320,36 +321,47 @@ static int prove_and_get_session(void) { /* ---- opening the browser ------------------------------------------------ */ /* The one thing the launcher cannot do for us: hand a URL to the desktop. - * There is no SDL_OpenURL in this runtime, so this is per platform. A failure - * is not fatal -- the player can still be told to open the page themselves. */ + * There is no SDL_OpenURL in this runtime, so this is per platform. + * + * NO SHELL. An earlier version built a `xdg-open ''` command string and + * screened the URL for shell metacharacters -- which refused every real + * authorize URL, because an OAuth query string is full of `&`. Passing the URL + * as a single argv element removes the quoting problem instead of policing it, + * and there is nothing left for a metacharacter to escape into. */ static void account_open_url(const char *url) { if (!url || !url[0]) return; + /* Still scheme-checked: xdg-open will happily act on a file:// URL or a + * local path, and this only ever legitimately receives http(s). */ + if (strncmp(url, "http://", 7) != 0 && strncmp(url, "https://", 8) != 0) { + fprintf(stderr, "%s: refusing to open a non-http(s) URL\n", "psx_account"); + return; + } #if defined(_WIN32) + /* Takes the URL directly; no command line is built. */ ShellExecuteA(NULL, "open", url, NULL, NULL, SW_SHOWNORMAL); #else { - char cmd[1200]; #if defined(__APPLE__) const char *opener = "open"; #else const char *opener = "xdg-open"; #endif - /* The URL comes from our own lobby server, but it still goes through a - * shell, so refuse anything that is not a plain http(s) URL rather - * than trusting the source. */ - const char *q; - if (strncmp(url, "http://", 7) != 0 && strncmp(url, "https://", 8) != 0) return; - for (q = url; *q; ++q) { - if (*q == '\'' || *q == '"' || *q == '`' || *q == '$' || *q == '\\' || - *q == ';' || *q == '|' || *q == '&' || *q == '<' || *q == '>' || - (unsigned char)*q < 0x20) { - fprintf(stderr, "psx_account: refusing to open a suspicious URL\n"); - return; + pid_t pid = fork(); + if (pid == 0) { + /* Double-fork: the opener is reparented to init, so it outlives + * this process and leaves no zombie for a game loop to reap. */ + if (fork() == 0) { + execlp(opener, opener, url, (char *)NULL); + _exit(127); } + _exit(0); } - snprintf(cmd, sizeof(cmd), "%s '%s' >/dev/null 2>&1 &", opener, url); - if (system(cmd) != 0) { - fprintf(stderr, "psx_account: could not open a browser; visit:\n %s\n", url); + if (pid > 0) { + int st = 0; + (void)waitpid(pid, &st, 0); + } else { + fprintf(stderr, "%s: could not open a browser; visit:\n %s\n", + "psx_account", url); } } #endif From 69e31bcd39e531187b995cf086d24eef4c8c083f Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 8 Sep 2026 09:56:11 -0400 Subject: [PATCH 3/9] netplay: use the shared auth module instead of a private copy psx_netplay_auth.c/h are gone; recomp-net's rnet_auth is the one copy, and the submodule is bumped to pick it up. Callers change name only (psx_account_* -> rnet_account_*). The duplication was a day old and had already cost a bug: the URL screen that refused every OAuth authorize URL had to be fixed in this file and in snesrecomp's copy separately, and the second was caught by remembering rather than by anything structural. Lobby client compiles with the same 12 pre-existing warnings as before and no errors. main.cpp still cannot be compiled here -- it needs a generated tree -- so its rename is verified by symbol cross-check only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015C38nLRmmyXHyJ7P1DiTxg --- lib/recomp-net | 2 +- runtime/include/psx_netplay_auth.h | 63 --- runtime/runtime.cmake | 1 - runtime/src/main.cpp | 24 +- runtime/src/psx_lobby_client.c | 4 +- runtime/src/psx_netplay_auth.c | 631 ----------------------------- 6 files changed, 15 insertions(+), 710 deletions(-) delete mode 100644 runtime/include/psx_netplay_auth.h delete mode 100644 runtime/src/psx_netplay_auth.c diff --git a/lib/recomp-net b/lib/recomp-net index 30772f6e..85230040 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 30772f6e78845b937c270e4af869a6b989e91b07 +Subproject commit 85230040c089bf847b9a1dadd1ed4fbc0d395ee6 diff --git a/runtime/include/psx_netplay_auth.h b/runtime/include/psx_netplay_auth.h deleted file mode 100644 index c227d9be..00000000 --- a/runtime/include/psx_netplay_auth.h +++ /dev/null @@ -1,63 +0,0 @@ -/* psx_netplay_auth.h -- Discord sign-in and the device key, for the launcher. - * - * Backs recomp-ui's optional `account_*` netplay callbacks. Everything here is - * optional at runtime: a lobby server whose operator never configured Discord - * answers 503 to a login start, and this reports "not available" so the - * launcher draws no sign-in at all. A player who never signs in is a guest, - * which is what the runtime has always been. - * - * Two credentials, and the difference matters: - * - * netplay_secret Long-lived, per device, written to a file beside the - * config. NEVER sent anywhere. Proved by HMAC over a - * server nonce, because this runtime has no TLS and a - * permanent bearer token in cleartext would be captured - * once and reused forever. - * session Short-lived JWT, minted from that proof, and the only - * thing that goes to the lobby (in `hello`). - * - * The secret file is what makes a browserless device work: sign in once on a - * PC, and the file travels with a build installed on a handheld. It is a - * password in a file -- do not put it in anything you distribute. - */ -#ifndef PSX_NETPLAY_AUTH_H -#define PSX_NETPLAY_AUTH_H - -#ifdef __cplusplus -extern "C" { -#endif - -/* Mirrors recomp-ui's RECOMP_LAUNCHER_ACCOUNT_* so this header does not - * depend on the launcher's. */ -enum { - PSX_ACCOUNT_GUEST = 0, - PSX_ACCOUNT_WAITING = 1, - PSX_ACCOUNT_SIGNED_IN = 2, - PSX_ACCOUNT_FAILED = 3 -}; - -/* `ws_url` is the lobby URL the client is configured with; the HTTP endpoints - * live on the same host and port. Safe to call again when the URL changes. */ -void psx_account_init(const char *ws_url); - -/* Call once per frame. Drives the worker handshake and, when a stored key - * exists, redeems it for a session on first use. */ -void psx_account_pump(void); - -int psx_account_available(void); -int psx_account_login_begin(void); -int psx_account_state(void); -const char *psx_account_handle(void); -const char *psx_account_username(void); -const char *psx_account_error(void); -int psx_account_sign_out(void); -int psx_account_set_handle(const char *handle); - -/* The session for `hello`, or "" when this client is a guest. */ -const char *psx_account_session(void); - -#ifdef __cplusplus -} -#endif - -#endif /* PSX_NETPLAY_AUTH_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index f2396a21..59edc928 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -374,7 +374,6 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/nd_intro_ot.c ${PSXRECOMP_ROOT}/runtime/src/crc32.c ${PSXRECOMP_ROOT}/runtime/src/psx_sha256.c - ${PSXRECOMP_ROOT}/runtime/src/psx_netplay_auth.c ${PSXRECOMP_ROOT}/runtime/src/disc_identity.cpp ${PSXRECOMP_ROOT}/runtime/src/cue_sheet.cpp ${PSXRECOMP_ROOT}/runtime/src/disc_path.cpp diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c593be03..837087c5 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -56,7 +56,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "psx_netplay_rb.h" #include "psx_selfcheck.h" #include "psx_lobby_client.h" -#include "psx_netplay_auth.h" +#include "recomp_net/auth.h" #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */ #endif @@ -9995,14 +9995,14 @@ namespace { * Thin adapters over psx_netplay_auth, which owns the HTTP, the worker * thread and the device key. Nothing here blocks a frame except the * rename, which is one round trip and wants a verdict for its modal. */ - int ae_np_account_available(void*) { return psx_account_available(); } - int ae_np_account_login_begin(void*) { return psx_account_login_begin(); } - int ae_np_account_state(void*) { return psx_account_state(); } - const char* ae_np_account_handle(void*) { return psx_account_handle(); } - const char* ae_np_account_username(void*) { return psx_account_username(); } - const char* ae_np_account_error(void*) { return psx_account_error(); } - int ae_np_account_sign_out(void*) { return psx_account_sign_out(); } - int ae_np_account_set_handle(void*, const char* h) { return psx_account_set_handle(h); } + int ae_np_account_available(void*) { return rnet_account_available(); } + int ae_np_account_login_begin(void*) { return rnet_account_login_begin(); } + int ae_np_account_state(void*) { return rnet_account_state(); } + const char* ae_np_account_handle(void*) { return rnet_account_handle(); } + const char* ae_np_account_username(void*) { return rnet_account_username(); } + const char* ae_np_account_error(void*) { return rnet_account_error(); } + int ae_np_account_sign_out(void*) { return rnet_account_sign_out(); } + int ae_np_account_set_handle(void*, const char* h) { return rnet_account_set_handle(h); } int ae_np_chat_count(void*) { ae_np_chat_track_room(); @@ -10211,7 +10211,7 @@ namespace { ae_np_save_identity(nullptr, g_lnch_lobby_url.c_str()); /* The auth endpoints live on the same host and port as the lobby * socket, so the sign-in follows whatever server the player points at. */ - psx_account_init(g_lnch_lobby_url.c_str()); + rnet_account_init(g_lnch_lobby_url.c_str()); } int ae_np_connect(void*) { @@ -10792,8 +10792,8 @@ namespace { psx_lobby_pump(); /* Redeems a stored device key on the first pump, so a machine that has * signed in once comes up signed in with no player action. */ - psx_account_init(g_lnch_lobby_url.c_str()); - psx_account_pump(); + rnet_account_init(g_lnch_lobby_url.c_str()); + rnet_account_pump(); ae_np_lan_browse_pump(); ae_np_lan_udp_pump(); /* Lobby UI has no Ready toggle; production WS still requires every diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index ea2a2706..73ff7350 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -153,7 +153,7 @@ void psx_lobby_clear_launch_pending(void) {} #include "recomp_net/lan_beacon.h" #include "recomp_net/rtt_probe.h" #include "recomp_net/chat_filter.h" -#include "psx_netplay_auth.h" /* optional Discord session for `hello` */ +#include "recomp_net/auth.h" /* optional Discord session for `hello` */ #include "host_time.h" #if defined(_WIN32) @@ -1871,7 +1871,7 @@ static void queue_hello(void) char game_esc[PSX_LOBBY_NAME_LEN * 2 + 8]; char sess_esc[2048]; char msg[PSX_LOBBY_NAME_LEN * 4 + 2176]; - const char *sess = psx_account_session(); + const char *sess = rnet_account_session(); json_escape(g_lc.display_name, name_esc, sizeof(name_esc)); json_escape(g_lc.filter_game_name, game_esc, sizeof(game_esc)); /* The session is OPTIONAL and omitted entirely when this client is a diff --git a/runtime/src/psx_netplay_auth.c b/runtime/src/psx_netplay_auth.c deleted file mode 100644 index d49e2659..00000000 --- a/runtime/src/psx_netplay_auth.c +++ /dev/null @@ -1,631 +0,0 @@ -/* psx_netplay_auth.c -- see psx_netplay_auth.h. - * - * All network work happens on one worker thread; the launcher only ever reads - * flags this file publishes. That is the same shape the lobby client already - * uses for its off-thread connect, and it is why a login cannot stall a frame. - */ - -#include "psx_netplay_auth.h" -#include "psx_sha256.h" - -#include -#include -#include -#include -#include -#include - -#if defined(_WIN32) -#include -#include -#include -#include -#define close closesocket -typedef HANDLE auth_thread_t; -#else -#include -#include -#include -#include -#include -#include -#include -#include -typedef pthread_t auth_thread_t; -#endif - -#define SECRET_MAX 96 -#define SESSION_MAX 1024 -#define NAME_MAX_LEN 64 -#define ID_MAX 64 - -/* What the worker is doing. The launcher polls psx_account_state(). */ -enum { JOB_NONE = 0, JOB_LOGIN, JOB_SESSION, JOB_SET_HANDLE, JOB_SIGN_OUT }; - -static struct { - char host[192]; - int port; - - /* Published to the launcher. Written by the worker, read on the UI - * thread; single-writer plus aligned word stores, which is what the - * lobby client already relies on for its connect flags. */ - volatile int state; /* PSX_ACCOUNT_* */ - volatile int available; /* -1 unknown, 0 no logins here, 1 yes */ - volatile int busy; - - char secret[SECRET_MAX]; - char session[SESSION_MAX]; - char player_id[ID_MAX]; - char handle[NAME_MAX_LEN]; - char username[NAME_MAX_LEN]; - char error[192]; - - /* Login in flight. */ - char pair_code[128]; - char pending_handle[NAME_MAX_LEN]; - - int job; - int thread_valid; - auth_thread_t thread; - int initialized; - int tried_stored_key; -} g; - -/* ---- small helpers ------------------------------------------------------ */ - -static void set_err(const char *e) { - snprintf(g.error, sizeof(g.error), "%s", e ? e : ""); -} - -/* Minimal extraction for the flat, server-generated JSON these endpoints - * return. Not a parser: it is the same shape psx_lobby_client.c already uses, - * and the bodies come from our own server. */ -static int json_str(const char *json, const char *key, char *out, size_t cap) { - char pat[64]; - const char *p; - size_t o = 0; - if (!json || !out || cap == 0) return 0; - out[0] = '\0'; - snprintf(pat, sizeof(pat), "\"%s\"", key); - p = strstr(json, pat); - if (!p) return 0; - p = strchr(p + strlen(pat), ':'); - if (!p) return 0; - ++p; - while (*p && isspace((unsigned char)*p)) ++p; - if (*p != '"') return 0; - ++p; - while (*p && *p != '"' && o + 1 < cap) { - if (*p == '\\' && p[1]) { - ++p; - switch (*p) { - case 'n': out[o++] = '\n'; break; - case 't': out[o++] = '\t'; break; - case 'r': out[o++] = '\r'; break; - default: out[o++] = *p; break; - } - ++p; - continue; - } - out[o++] = *p++; - } - out[o] = '\0'; - return o > 0; -} - -/* Escape for a JSON string body. Same rules as the lobby client's escaper: - * quote and backslash escaped, control characters dropped. */ -static void json_esc(const char *in, char *out, size_t cap) { - size_t o = 0; - if (!out || cap == 0) return; - while (in && *in && o + 2 < cap) { - unsigned char c = (unsigned char)*in++; - if (c == '"' || c == '\\') { - if (o + 3 >= cap) break; - out[o++] = '\\'; - out[o++] = (char)c; - } else if (c >= 0x20) { - out[o++] = (char)c; - } - } - out[o] = '\0'; -} - -static void hex_encode(const unsigned char *in, size_t n, char *out) { - static const char H[] = "0123456789abcdef"; - size_t i; - for (i = 0; i < n; ++i) { - out[i * 2] = H[in[i] >> 4]; - out[i * 2 + 1] = H[in[i] & 15]; - } - out[n * 2] = '\0'; -} - -/* HMAC-SHA256, keyed by the hex verifier string, over the nonce. The server - * computes the identical thing (secrets::expected_proof). */ -static void hmac_sha256_hex(const char *key, const char *msg, char out_hex[65]) { - unsigned char k[64], inner[32], outer[32], pad[64]; - unsigned char tmp[96]; - psx_sha256_ctx c; - size_t klen = strlen(key), i; - - memset(k, 0, sizeof(k)); - if (klen > 64) { - psx_sha256_compute((const unsigned char *)key, klen, k); - } else { - memcpy(k, key, klen); - } - - for (i = 0; i < 64; ++i) pad[i] = k[i] ^ 0x36; - psx_sha256_init(&c); - psx_sha256_update(&c, pad, 64); - psx_sha256_update(&c, (const unsigned char *)msg, strlen(msg)); - psx_sha256_final(&c, inner); - - for (i = 0; i < 64; ++i) pad[i] = k[i] ^ 0x5c; - memcpy(tmp, pad, 64); - memcpy(tmp + 64, inner, 32); - psx_sha256_compute(tmp, 96, outer); - hex_encode(outer, 32, out_hex); -} - -/* ---- the secret file ---------------------------------------------------- */ - -static void secret_path(char *out, size_t cap) { - /* Beside the other runtime config (keybinds.ini, config.ini), so a build - * copied to another device carries it the same way. */ - snprintf(out, cap, "netplay_secret"); -} - -static void secret_load(void) { - char path[256]; - FILE *f; - size_t n; - secret_path(path, sizeof(path)); - g.secret[0] = '\0'; - g.player_id[0] = '\0'; - f = fopen(path, "rb"); - if (!f) return; - n = fread(g.secret, 1, sizeof(g.secret) - 1, f); - g.secret[n] = '\0'; - fclose(f); - /* Format: " \n". Tolerate a trailing newline. */ - { - char *sp = strchr(g.secret, ' '); - char *nl; - if (sp) { - size_t idn = (size_t)(sp - g.secret); - if (idn >= sizeof(g.player_id)) idn = sizeof(g.player_id) - 1; - memcpy(g.player_id, g.secret, idn); - g.player_id[idn] = '\0'; - memmove(g.secret, sp + 1, strlen(sp + 1) + 1); - } - nl = strpbrk(g.secret, "\r\n"); - if (nl) *nl = '\0'; - } -} - -static void secret_store(const char *player_id, const char *secret) { - char path[256]; - FILE *f; - secret_path(path, sizeof(path)); - f = fopen(path, "wb"); - if (!f) return; - fprintf(f, "%s %s\n", player_id ? player_id : "", secret ? secret : ""); - fclose(f); -#if !defined(_WIN32) - /* A password in a file: keep it to the owner. */ - (void)chmod(path, S_IRUSR | S_IWUSR); -#endif - snprintf(g.secret, sizeof(g.secret), "%s", secret ? secret : ""); - snprintf(g.player_id, sizeof(g.player_id), "%s", player_id ? player_id : ""); -} - -static void secret_forget(void) { - char path[256]; - secret_path(path, sizeof(path)); - remove(path); - g.secret[0] = '\0'; - g.player_id[0] = '\0'; -} - -/* ---- blocking HTTP POST (worker thread only) ---------------------------- */ - -/* Returns the HTTP status, or <0 on a transport failure. Body is copied into - * `body` (truncated to fit), which is enough for these small JSON replies. */ -static int http_post(const char *path, const char *json, char *body, size_t cap) { - struct addrinfo hints, *res = NULL, *ai; - char portstr[16], req[2048]; - char buf[4096]; - int fd = -1, status = -1; - size_t total = 0; - const char *hdr_end; - - if (body && cap) body[0] = '\0'; - snprintf(portstr, sizeof(portstr), "%d", g.port); - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_STREAM; - if (getaddrinfo(g.host, portstr, &hints, &res) != 0 || !res) return -1; - for (ai = res; ai; ai = ai->ai_next) { - fd = (int)socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); - if (fd < 0) continue; - if (connect(fd, ai->ai_addr, (int)ai->ai_addrlen) == 0) break; - close(fd); - fd = -1; - } - freeaddrinfo(res); - if (fd < 0) return -2; - - snprintf(req, sizeof(req), - "POST %s HTTP/1.1\r\n" - "Host: %s:%d\r\n" - "Content-Type: application/json\r\n" - "Content-Length: %d\r\n" - "Connection: close\r\n\r\n%s", - path, g.host, g.port, (int)strlen(json), json); - if (send(fd, req, (int)strlen(req), 0) < 0) { - close(fd); - return -3; - } - - for (;;) { - int n = (int)recv(fd, buf + total, (int)(sizeof(buf) - 1 - total), 0); - if (n <= 0) break; - total += (size_t)n; - if (total >= sizeof(buf) - 1) break; - } - close(fd); - buf[total] = '\0'; - if (total < 12) return -4; - status = atoi(buf + 9); /* "HTTP/1.1 NNN ..." */ - hdr_end = strstr(buf, "\r\n\r\n"); - if (hdr_end && body && cap) { - snprintf(body, cap, "%s", hdr_end + 4); - } - return status; -} - -/* ---- one challenge/proof round ------------------------------------------ */ - -/* Fills `session` and the display fields. Returns 1 on success. The key never - * leaves this function: only the HMAC does. */ -static int prove_and_get_session(void) { - char body[2048], reqbuf[512], nonce[128], verifier_hex[65], proof[65]; - unsigned char digest[32]; - char id_esc[128]; - int st; - - if (!g.secret[0] || !g.player_id[0]) return 0; - - json_esc(g.player_id, id_esc, sizeof(id_esc)); - snprintf(reqbuf, sizeof(reqbuf), "{\"player_id\":\"%s\"}", id_esc); - st = http_post("/auth/challenge", reqbuf, body, sizeof(body)); - if (st != 200 || !json_str(body, "nonce", nonce, sizeof(nonce))) return 0; - - psx_sha256_compute((const unsigned char *)g.secret, strlen(g.secret), digest); - hex_encode(digest, 32, verifier_hex); - hmac_sha256_hex(verifier_hex, nonce, proof); - - snprintf(reqbuf, sizeof(reqbuf), - "{\"player_id\":\"%s\",\"nonce\":\"%s\",\"proof\":\"%s\"}", - id_esc, nonce, proof); - st = http_post("/auth/session", reqbuf, body, sizeof(body)); - if (st != 200) return 0; - json_str(body, "session", g.session, sizeof(g.session)); - json_str(body, "handle", g.handle, sizeof(g.handle)); - json_str(body, "discord_username", g.username, sizeof(g.username)); - return g.session[0] ? 1 : 0; -} - -/* ---- opening the browser ------------------------------------------------ */ - -/* The one thing the launcher cannot do for us: hand a URL to the desktop. - * There is no SDL_OpenURL in this runtime, so this is per platform. - * - * NO SHELL. An earlier version built a `xdg-open ''` command string and - * screened the URL for shell metacharacters -- which refused every real - * authorize URL, because an OAuth query string is full of `&`. Passing the URL - * as a single argv element removes the quoting problem instead of policing it, - * and there is nothing left for a metacharacter to escape into. */ -static void account_open_url(const char *url) { - if (!url || !url[0]) return; - /* Still scheme-checked: xdg-open will happily act on a file:// URL or a - * local path, and this only ever legitimately receives http(s). */ - if (strncmp(url, "http://", 7) != 0 && strncmp(url, "https://", 8) != 0) { - fprintf(stderr, "%s: refusing to open a non-http(s) URL\n", "psx_account"); - return; - } -#if defined(_WIN32) - /* Takes the URL directly; no command line is built. */ - ShellExecuteA(NULL, "open", url, NULL, NULL, SW_SHOWNORMAL); -#else - { -#if defined(__APPLE__) - const char *opener = "open"; -#else - const char *opener = "xdg-open"; -#endif - pid_t pid = fork(); - if (pid == 0) { - /* Double-fork: the opener is reparented to init, so it outlives - * this process and leaves no zombie for a game loop to reap. */ - if (fork() == 0) { - execlp(opener, opener, url, (char *)NULL); - _exit(127); - } - _exit(0); - } - if (pid > 0) { - int st = 0; - (void)waitpid(pid, &st, 0); - } else { - fprintf(stderr, "%s: could not open a browser; visit:\n %s\n", - "psx_account", url); - } - } -#endif -} - -/* ---- worker ------------------------------------------------------------- */ - -static void job_login(void) { - char body[2048], url[1024]; - int st, tries; - - st = http_post("/auth/discord/start", "{}", body, sizeof(body)); - if (st == 503) { - g.available = 0; - set_err("This lobby server does not offer Discord sign-in."); - g.state = PSX_ACCOUNT_FAILED; - return; - } - if (st != 200 || !json_str(body, "url", url, sizeof(url)) || - !json_str(body, "code", g.pair_code, sizeof(g.pair_code))) { - set_err("Could not reach the lobby server to start the sign-in."); - g.state = PSX_ACCOUNT_FAILED; - return; - } - g.available = 1; - account_open_url(url); - - /* Poll until the server has an answer. The pairing code expires server - * side after ten minutes; stop a little before that rather than spinning - * forever if the player closed the tab. */ - for (tries = 0; tries < 300; ++tries) { - char req[256]; - char code_esc[160]; - json_esc(g.pair_code, code_esc, sizeof(code_esc)); - snprintf(req, sizeof(req), "{\"code\":\"%s\"}", code_esc); - st = http_post("/auth/discord/poll", req, body, sizeof(body)); - if (st == 200) { - char secret[SECRET_MAX], pid[ID_MAX]; - json_str(body, "session", g.session, sizeof(g.session)); - json_str(body, "handle", g.handle, sizeof(g.handle)); - json_str(body, "discord_username", g.username, sizeof(g.username)); - if (json_str(body, "netplay_secret", secret, sizeof(secret)) && - json_str(body, "player_id", pid, sizeof(pid))) { - /* Returned exactly once. Store it now or it is gone. */ - secret_store(pid, secret); - } - g.state = PSX_ACCOUNT_SIGNED_IN; - return; - } - if (st == 202) { -#if defined(_WIN32) - Sleep(2000); -#else - struct timespec ts; ts.tv_sec = 2; ts.tv_nsec = 0; - nanosleep(&ts, NULL); -#endif - continue; - } - set_err("Discord sign-in did not complete. Try again."); - g.state = PSX_ACCOUNT_FAILED; - return; - } - set_err("Sign-in timed out. Try again."); - g.state = PSX_ACCOUNT_FAILED; -} - -static void job_session(void) { - if (prove_and_get_session()) { - g.state = PSX_ACCOUNT_SIGNED_IN; - } else { - /* A stored key that no longer works (revoked, or a different server): - * drop it and fall back to guest rather than nagging every launch. */ - secret_forget(); - g.session[0] = '\0'; - g.state = PSX_ACCOUNT_GUEST; - } -} - -static void job_set_handle(void) { - char body[1024], req[768], esc[160], id_esc[128], nonce[128]; - char verifier_hex[65], proof[65]; - unsigned char digest[32]; - int st; - - if (!g.secret[0] || !g.player_id[0]) { - set_err("Sign in before changing your name."); - return; - } - json_esc(g.player_id, id_esc, sizeof(id_esc)); - snprintf(req, sizeof(req), "{\"player_id\":\"%s\"}", id_esc); - if (http_post("/auth/challenge", req, body, sizeof(body)) != 200 || - !json_str(body, "nonce", nonce, sizeof(nonce))) { - set_err("Could not reach the lobby server."); - return; - } - psx_sha256_compute((const unsigned char *)g.secret, strlen(g.secret), digest); - hex_encode(digest, 32, verifier_hex); - hmac_sha256_hex(verifier_hex, nonce, proof); - - json_esc(g.pending_handle, esc, sizeof(esc)); - snprintf(req, sizeof(req), - "{\"player_id\":\"%s\",\"nonce\":\"%s\",\"proof\":\"%s\",\"handle\":\"%s\"}", - id_esc, nonce, proof, esc); - st = http_post("/auth/handle", req, body, sizeof(body)); - if (st == 200) { - json_str(body, "handle", g.handle, sizeof(g.handle)); - } else { - set_err("That name can't be used. Please pick another one."); - } -} - -static void job_sign_out(void) { - char body[512], req[512], id_esc[128], nonce[128], verifier_hex[65], proof[65]; - unsigned char digest[32]; - - /* Best effort: tell the server to retire this device's key, then forget it - * locally whatever happened. Signing out must work offline. */ - if (g.secret[0] && g.player_id[0]) { - json_esc(g.player_id, id_esc, sizeof(id_esc)); - snprintf(req, sizeof(req), "{\"player_id\":\"%s\"}", id_esc); - if (http_post("/auth/challenge", req, body, sizeof(body)) == 200 && - json_str(body, "nonce", nonce, sizeof(nonce))) { - psx_sha256_compute((const unsigned char *)g.secret, strlen(g.secret), digest); - hex_encode(digest, 32, verifier_hex); - hmac_sha256_hex(verifier_hex, nonce, proof); - snprintf(req, sizeof(req), - "{\"player_id\":\"%s\",\"nonce\":\"%s\",\"proof\":\"%s\"}", - id_esc, nonce, proof); - (void)http_post("/auth/secret/revoke", req, body, sizeof(body)); - } - } - secret_forget(); - g.session[0] = '\0'; - g.handle[0] = '\0'; - g.username[0] = '\0'; - g.state = PSX_ACCOUNT_GUEST; -} - -#if defined(_WIN32) -static unsigned __stdcall auth_worker(void *arg) { -#else -static void *auth_worker(void *arg) { -#endif - int job = (int)(intptr_t)arg; - switch (job) { - case JOB_LOGIN: job_login(); break; - case JOB_SESSION: job_session(); break; - case JOB_SET_HANDLE: job_set_handle(); break; - case JOB_SIGN_OUT: job_sign_out(); break; - default: break; - } - g.busy = 0; -#if defined(_WIN32) - return 0; -#else - return NULL; -#endif -} - -static int start_job(int job) { - if (g.busy) return -1; - g.busy = 1; - g.job = job; -#if defined(_WIN32) - { - uintptr_t th = _beginthreadex(NULL, 0, auth_worker, (void *)(intptr_t)job, 0, NULL); - if (!th) { g.busy = 0; return -1; } - g.thread = (HANDLE)th; - } -#else - if (pthread_create(&g.thread, NULL, auth_worker, (void *)(intptr_t)job) != 0) { - g.busy = 0; - return -1; - } - pthread_detach(g.thread); -#endif - g.thread_valid = 1; - return 0; -} - -/* ---- public surface ----------------------------------------------------- */ - -void psx_account_init(const char *ws_url) { - const char *p = ws_url; - const char *slash; - const char *colon; - char hostport[192]; - - memset(g.host, 0, sizeof(g.host)); - g.port = 8765; - if (p) { - if (strncmp(p, "ws://", 5) == 0) p += 5; - else if (strncmp(p, "wss://", 6) == 0) p += 6; - slash = strchr(p, '/'); - snprintf(hostport, sizeof(hostport), "%.*s", - slash ? (int)(slash - p) : (int)strlen(p), p); - colon = strrchr(hostport, ':'); - if (colon && strchr(colon, ']') == NULL) { - g.port = atoi(colon + 1); - *(char *)colon = '\0'; - } - snprintf(g.host, sizeof(g.host), "%s", hostport); - } - if (!g.initialized) { - g.available = -1; - g.state = PSX_ACCOUNT_GUEST; - secret_load(); - g.initialized = 1; - } -} - -void psx_account_pump(void) { - if (!g.initialized || g.busy) return; - /* A stored key is redeemed once, lazily, on the first pump: the launcher - * should come up signed in without the player doing anything. */ - if (!g.tried_stored_key && g.secret[0] && g.player_id[0]) { - g.tried_stored_key = 1; - g.state = PSX_ACCOUNT_WAITING; - (void)start_job(JOB_SESSION); - } -} - -int psx_account_available(void) { - /* Unknown until something asks the server. Offering the button is right: - * the first start() settles it, and a 503 turns it off for good. */ - return g.available != 0; -} - -int psx_account_login_begin(void) { - if (g.busy) return -1; - g.error[0] = '\0'; - g.state = PSX_ACCOUNT_WAITING; - return start_job(JOB_LOGIN); -} - -int psx_account_state(void) { return g.state; } -const char *psx_account_handle(void) { return g.handle; } -const char *psx_account_username(void) { return g.username; } -const char *psx_account_error(void) { return g.error; } -const char *psx_account_session(void) { return g.session; } - -int psx_account_sign_out(void) { - if (g.busy) return -1; - return start_job(JOB_SIGN_OUT); -} - -int psx_account_set_handle(const char *handle) { - if (g.busy || !handle || !handle[0]) return -1; - snprintf(g.pending_handle, sizeof(g.pending_handle), "%s", handle); - g.error[0] = '\0'; - if (start_job(JOB_SET_HANDLE) != 0) return -1; - /* The launcher wants a synchronous verdict for its modal, and the request - * is one round trip on a LAN-ish connection: wait briefly rather than - * inventing an async path through the UI for a rename. */ - { - int spins = 0; - while (g.busy && spins++ < 400) { -#if defined(_WIN32) - Sleep(10); -#else - struct timespec ts; ts.tv_sec = 0; ts.tv_nsec = 10 * 1000 * 1000; - nanosleep(&ts, NULL); -#endif - } - } - return g.error[0] ? -1 : 0; -} From e19c25d3fbc6bbdd472988e92223899dc3ac2556 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 8 Sep 2026 09:48:21 -0400 Subject: [PATCH 4/9] yes --- lib/retcomm-rbengine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/retcomm-rbengine b/lib/retcomm-rbengine index ebd94a47..a7b98507 160000 --- a/lib/retcomm-rbengine +++ b/lib/retcomm-rbengine @@ -1 +1 @@ -Subproject commit ebd94a4729abe2c0615070cef3ffe05b3f9ebf28 +Subproject commit a7b98507a62fe00e5aec3b90c52a4134f3c174bc From 7959acf261ad1b241837a8f62682a0af81ef56c8 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 8 Sep 2026 10:07:43 -0400 Subject: [PATCH 5/9] netplay: bump recomp-net for the sign-in availability fix A zero-initialised static made rnet_account_available() answer "no logins here" until the netplay pump had run, which is why the first NETPLAY click skipped the sign-in page. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015C38nLRmmyXHyJ7P1DiTxg --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index 85230040..e9644544 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 85230040c089bf847b9a1dadd1ed4fbc0d395ee6 +Subproject commit e964454448afa40afc7e6a11fb26cde4401ceaf1 From f0bbbabe798b2eeed525c1b8fc6c63636b43d88b Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 8 Sep 2026 10:17:52 -0400 Subject: [PATCH 6/9] netplay: bump recomp-net for cancellable sign-in and clearer failures Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015C38nLRmmyXHyJ7P1DiTxg --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index e9644544..b4c58fcf 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit e964454448afa40afc7e6a11fb26cde4401ceaf1 +Subproject commit b4c58fcfcdd3faadb3ba74da9a138ec1955c4540 From cf9648e7ea950a8eb75382930233f8e051b2a413 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 9 Sep 2026 17:29:48 -0400 Subject: [PATCH 7/9] Rename the product from RetComM to Retro The launcher and studio ship as Retro Launcher and Retro Studio now, from the RetroPortingToolKit org, so the old product name is gone from display text, documentation and release asset filenames. The split is by case, and it is not cosmetic. CamelCase RetComM was always the product; lowercase retcomm and RETCOMM_ are identifiers -- the C++ namespace, the binaries, the data directories, the CMake and environment variables, the Cloudflare service names, the readme markers. Renaming those would orphan existing installs for no benefit, so they are untouched: 1101 lowercase retcomm and 205 RETCOMM_ still stand in the launcher alone. Four things are deliberately still spelled RetComM, and three of them would have been real breakage: - RetComM-Data is CamelCase but it is a directory on disk -- \RetComM-Data\{runtime,config,data} for a portable install. Renaming it strands every portable user's config, toolchains and catalog cache. - TechnicallyComputers/RetComM-Launcher in readme_has_launcher() is the historical half of a both-slugs test. Renaming it makes it a duplicate of the line above and sends every port built before the transfer back to reporting its launcher section missing. - _LEGACY_LAUNCHER_RE matched "^## RetComM Launcher". A sweep renamed it, which would have silently stopped it matching every README already out there; it now accepts both headings. - The portable-stub lookup gained the pre-rename filenames back as fallbacks, because old zips stay on the releases page and a rollback downloads one. Release assets become Retro-Launcher-* / Retro-Studio-*. That is safe for installed clients: pick_launcher_asset() gates on *linux*/*windows*/ *macos* plus extension and arch, not on the product name, and the Windows portable stub is identified by its RCM1 trailer rather than its filename. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013DpaQqVguHFpBuCmdoFYWY --- .github/actions/fetch-toolchain/action.yml | 2 +- .gitmodules | 4 +- README.md | 2 +- THIRD_PARTY_ATTRIBUTION.md | 4 +- docs/GAME_PROJECT_SETUP.md | 22 +++++----- docs/GPU_FRAME_TOOLS.md | 4 +- docs/LOCAL_CODEGEN_SDK.md | 8 ++-- docs/NETPLAY.md | 2 +- docs/ci/HOST_ONLY_RELEASES.md | 10 ++--- docs/ci/README.md | 2 +- docs/ci/templates/setup-release.yml | 4 +- docs/config_schema.md | 4 +- host/psxrecomp_codegen_host.c | 10 ++--- lib/retcomm-rbengine | 2 +- psxrecomp_cli.py | 8 ++-- recompiler/src/analysis_export.h | 2 +- recompiler/src/full_function_emitter.cpp | 2 +- runtime/runtime.cmake | 6 +-- tools/duckstation/README.md | 2 +- tools/duckstation_oracle.py | 10 ++--- tools/fetch_toolchain.sh | 2 +- tools/gpu_frame_capture.py | 2 +- tools/gpu_parity.py | 2 +- tools/new_project_layout/fill_tokens.py | 2 +- tools/new_project_layout/probe_disc.py | 2 +- .../new_project_layout/project_studio/cli.py | 12 +++--- .../project_studio/detect.py | 12 +++--- .../project_studio/gitops.py | 6 +-- .../new_project_layout/project_studio/gui.py | 16 ++++---- .../project_studio/naming.py | 2 +- .../new_project_layout/project_studio/ops.py | 4 +- .../new_project_layout/project_studio/plan.py | 2 +- .../project_studio/readme_metrics.py | 41 +++++++++++++------ tools/new_project_layout/setup_project.ps1 | 8 ++-- tools/new_project_layout/setup_project.sh | 10 ++--- .../new_project_layout/templates/README.md.in | 16 ++++---- tools/new_project_layout/update_disc_set.py | 4 +- tools/package_setup_host.sh | 8 ++-- tools/prepare_disc.py | 2 +- tools/psx_gpu_frame.py | 2 +- tools/stage_setup_sdk.sh | 4 +- tools/tests/test_duckstation_oracle.py | 2 +- tools/toolchain_pack.py | 14 +++---- 43 files changed, 150 insertions(+), 135 deletions(-) diff --git a/.github/actions/fetch-toolchain/action.yml b/.github/actions/fetch-toolchain/action.yml index fc24ab2e..71cd437c 100644 --- a/.github/actions/fetch-toolchain/action.yml +++ b/.github/actions/fetch-toolchain/action.yml @@ -22,7 +22,7 @@ inputs: repo: description: GitHub owner/name for toolchain releases required: false - default: TechnicallyComputers/retcomm-toolchains + default: RetroPortingToolKit/RetroPorting-Toolchains outputs: toolchain-dir: diff --git a/.gitmodules b/.gitmodules index 6adc28bc..953e090d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,9 @@ [submodule "lib/recomp-net"] path = lib/recomp-net - url = https://github.com/TechnicallyComputers/recomp-net.git + url = https://github.com/RetroPortingToolKit/recomp-net.git branch = main [submodule "lib/retcomm-rbengine"] path = lib/retcomm-rbengine - url = https://github.com/TechnicallyComputers/retcomm-rbengine.git + url = https://github.com/RetroPortingToolKit/rbengine.git branch = main diff --git a/README.md b/README.md index 5157cb50..54c9d6d4 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Git for Windows also provides the `bash` the setup script uses. Then pick one of these for the compiler, CMake, and Ninja: - **Bundled toolchain (recommended).** Download `cmake-clang-v1-windows-x64.zip` - from [retcomm-toolchains](https://github.com/TechnicallyComputers/retcomm-toolchains/releases/latest), + from [retcomm-toolchains](https://github.com/RetroPortingToolKit/RetroPorting-Toolchains/releases/latest), unzip it (for example to `C:\retcomm-toolchain`), and in the PowerShell window you will run the setup from: diff --git a/THIRD_PARTY_ATTRIBUTION.md b/THIRD_PARTY_ATTRIBUTION.md index 0ad0cb39..ec09436b 100644 --- a/THIRD_PARTY_ATTRIBUTION.md +++ b/THIRD_PARTY_ATTRIBUTION.md @@ -86,7 +86,7 @@ All reuse keeps the original copyright and dual MIT/Apache-2.0 license. ## retcomm-studio — multi-disc project tooling -[retcomm-studio](https://github.com/TechnicallyComputers/retcomm-studio) by +[retcomm-studio](https://github.com/RetroPortingToolKit/Retro-Studio) by Alex Vanderveen, licensed **MIT** (notice: `LICENSE` in that repository). psxrecomp is PolyForm-NC, so this is permissive vendored into stricter — the MIT notice must ride along and is why this entry exists. @@ -107,5 +107,5 @@ written here because neither repository had it: `probe_disc.py scaffolding a project and destructive on a live one. Keep the pin above accurate when re-syncing. These files are the reason a -standalone setup-wizard install and a RetComM build produce the same +standalone setup-wizard install and a Retro build produce the same multi-disc `game.toml`; if the two drift, so do those two paths. diff --git a/docs/GAME_PROJECT_SETUP.md b/docs/GAME_PROJECT_SETUP.md index c5915f22..2735beba 100644 --- a/docs/GAME_PROJECT_SETUP.md +++ b/docs/GAME_PROJECT_SETUP.md @@ -298,7 +298,7 @@ multi-track `.cue`/`.bin` requirements. Everything game-specific lives at the **root** of *your* title repo. Framework and UI are submodules next to that code — not nested under each other. -The RetComM / Generate & rebuild CLI (`psxrecomp_cli.py`, `tools/prepare_disc.py`, +The Retro / Generate & rebuild CLI (`psxrecomp_cli.py`, `tools/prepare_disc.py`, pack helpers) ships **inside** the `psxrecomp` submodule. There is no separate `psxrecomp-sdk/` tree. @@ -307,12 +307,12 @@ YourGameRecomp/ # your git repo ├── .gitmodules ├── CMakeLists.txt # thin: psxrecomp_add_game_runtime(...) ├── game.toml # probe autofills identity / netplay gates -├── catalog_identity.json # RetComM / catalog digests + track_counts + disc_fp +├── catalog_identity.json # Retro / catalog digests + track_counts + disc_fp ├── framework_pins.txt # optional scaffold snapshot (gitlinks are authoritative) ├── README.md # scaffold stub (legal + quick start) ├── VERSION # release pin (e.g. 0.1.0) ├── assets/ -│ ├── psxrecomp.ico # default Windows app icon (RetComM-themed pad) +│ ├── psxrecomp.ico # default Windows app icon (Retro-themed pad) │ ├── psxrecomp.png # staged beside exe / packaging │ └── psxrecomp.svg # source mark (see psxrecomp/assets/) ├── seeds/ghidra_funcs.txt # probe: boot-EXE JAL seeds (grow over time) @@ -429,10 +429,10 @@ embedded `toolchain/`. The zip includes: - `recomp-ui/` sources (needed to rebuild) - On Windows: MinGW runtime DLLs beside the host and emitters -Players (or [RetComM](https://github.com/TechnicallyComputers/RetComM-Launcher)) -run **Generate once** (wizard or RetComM Build & Install) with a legal disc. -RetComM / the wizard download `cmake-clang-v1` from -[retcomm-toolchains](https://github.com/TechnicallyComputers/retcomm-toolchains) +Players (or [Retro](https://github.com/RetroPortingToolKit/Retro-Launcher)) +run **Generate once** (wizard or Retro Build & Install) with a legal disc. +Retro / the wizard download `cmake-clang-v1` from +[retcomm-toolchains](https://github.com/RetroPortingToolKit/RetroPorting-Toolchains) (or accept an offline zip / `RETCOMM_TOOLCHAIN_DIR`). Pass `--embed-toolchain` to `package_setup_host.sh` only for special offline-first packs. @@ -441,7 +441,7 @@ packs. | Action | Meaning | |--------|---------| -| **Update** (RetComM) | New setup-host zip → refresh source → cmake rebuild. Skips disc→C when `codegen-cache` fingerprints (ROM/BIOS/emitters) still match. | +| **Update** (Retro) | New setup-host zip → refresh source → cmake rebuild. Skips disc→C when `codegen-cache` fingerprints (ROM/BIOS/emitters) still match. | | **Generate & Rebuild** | Force regenerate game C from the disc, then rebuild. Use when emit inputs change or cache is wrong. | Ordinary host/UI releases do **not** require Generate & Rebuild. Details: @@ -559,7 +559,7 @@ Full action reference: `[ci/README.md](ci/README.md)`. ## Bundled release checklist Use this before tagging a setup-host release that matches other titles -(BPE / MotK / RetComM). +(BPE / MotK / Retro). ### Repository @@ -631,7 +631,7 @@ Use this before tagging a setup-host release that matches other titles ``` - [ ] Extract → run host → Generate with a legal disc succeeds end-to-end - [ ] After rebuild, game launches; saves/settings land beside the exe -- [ ] RetComM install/update (if you publish a catalog entry) uses this **same** +- [ ] Retro install/update (if you publish a catalog entry) uses this **same** ``` zip — no separate tools pack required; Update rebuilds via codegen-cache (not raw zip extract over a Play binary) @@ -648,4 +648,4 @@ Use this before tagging a setup-host release that matches other titles ``` no pre-generated game C) ``` -- [ ] Catalog / RetComM entry points at the release assets when ready \ No newline at end of file +- [ ] Catalog / Retro entry points at the release assets when ready \ No newline at end of file diff --git a/docs/GPU_FRAME_TOOLS.md b/docs/GPU_FRAME_TOOLS.md index 19d08bad..8aedbbc0 100644 --- a/docs/GPU_FRAME_TOOLS.md +++ b/docs/GPU_FRAME_TOOLS.md @@ -12,7 +12,7 @@ tools/gpu_parity.py frame-locked image parity vs DuckStation tools/tests/test_gpu_frame.py ``` -RetComM Studio's **Frames** tab is a viewer and launcher over exactly these +Retro Studio's **Frames** tab is a viewer and launcher over exactly these artifacts. It never decodes a GP0 packet itself, so a headless capture and the GUI can never disagree about what a frame contained. @@ -96,7 +96,7 @@ grep -E '^(PSX_DEBUG_TOOLS|CMAKE_BUILD_TYPE):' build-release/CMakeCache.txt `CMAKE_BUILD_TYPE` on an **existing** build dir does *not* flip it. Pass the `-D` explicitly, or start a fresh build dir. -In RetComM Studio this is the **Build tab → Debug tools** row: a selector that +In Retro Studio this is the **Build tab → Debug tools** row: a selector that injects the flag, a **Configure for debugging** button that sets Release + ON in one click, and a status line that reads the configured build dir and says whether the server will be there. The Frames tab shows the same line when it diff --git a/docs/LOCAL_CODEGEN_SDK.md b/docs/LOCAL_CODEGEN_SDK.md index e51c8363..42a67a59 100644 --- a/docs/LOCAL_CODEGEN_SDK.md +++ b/docs/LOCAL_CODEGEN_SDK.md @@ -1,7 +1,7 @@ # Local codegen SDK Headless contract for regenerating an existing psxrecomp game project from a -user-supplied disc. Intended for `recomp-ui` setup flows and RetComM launcher +user-supplied disc. Intended for `recomp-ui` setup flows and Retro launcher automation. This does **not** redistribute disc images. PGO (optional) runs **only on the user’s machine** during local rebuild when @@ -38,14 +38,14 @@ product binary (`psxrecomp_codegen_host_forward_if_built`). Opt out with | `tools/stage_setup_sdk.sh` | Pack: emitters, OpenBIOS checks, optional `toolchain/`, MinGW DLLs | | `tools/bundle_mingw_dlls.sh` | Windows: copy MinGW runtime DLLs next to host + emitters | | Project `toolchain/` | Stamp file `.psxrecomp-bin` pointing at the shared pack `bin/` | -| Shared toolchain cache | `%LOCALAPPDATA%/retcomm/toolchains/cmake-clang-v1/` (Windows) or `~/.local/share/retcomm/toolchains/cmake-clang-v1/` — same tree RetComM uses | +| Shared toolchain cache | `%LOCALAPPDATA%/retcomm/toolchains/cmake-clang-v1/` (Windows) or `~/.local/share/retcomm/toolchains/cmake-clang-v1/` — same tree Retro uses | | `docs/ci/` | Composite actions + [`templates/setup-release.yml`](ci/templates/setup-release.yml) | | `docs/GAME_PROJECT_SETUP.md` | Submodules, CI template usage, bundled-release checklist | | Game sources | `game.toml`, seeds, `CMakeLists.txt` at repo root; `psxrecomp/`, `recomp-ui/` submodules | -RetComM harvests emitters into the SDK cache and **downloads** `cmake-clang-v1` +Retro harvests emitters into the SDK cache and **downloads** `cmake-clang-v1` (no separate tools zip; game zips stay lean). The setup host and CLI install the -portable pack into the **shared RetComM cache** +portable pack into the **shared Retro cache** (`%LOCALAPPDATA%/retcomm/toolchains/cmake-clang-v1//`, or XDG equivalent) with host-native `curl` + `tar`/`unzip` when possible — so Microsoft Store Python AppData redirection cannot hide cmake. Offline zips unpack to the same diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 9d8d68d4..8c4e5bbd 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -156,7 +156,7 @@ asymmetric NAT does not strand players on failed ICE attempts. signaling, and fallbacks. With `PSX_NETPLAY=ON`, `runtime.cmake` defaults `RNET_ENABLE_ICE=ON` (libjuice) before adding `recomp-net`. Pass `-DRNET_ENABLE_ICE=OFF` only for LAN-only / no-FetchContent builds. -libjuice is pulled as a **pinned URL tarball** (not `git clone`) so RetComM +libjuice is pulled as a **pinned URL tarball** (not `git clone`) so Retro AppImage / mismatched-libcurl hosts can still configure; offline builds can vendor `lib/recomp-net/third_party/libjuice` or set `-DRNET_LIBJUICE_ROOT`. “Force TURN” in the UI can raise delay floors for relay-heavy paths; it diff --git a/docs/ci/HOST_ONLY_RELEASES.md b/docs/ci/HOST_ONLY_RELEASES.md index 9bea7178..0abecf41 100644 --- a/docs/ci/HOST_ONLY_RELEASES.md +++ b/docs/ci/HOST_ONLY_RELEASES.md @@ -33,17 +33,17 @@ the portable llvm-mingw fetch; host compile stays on MSYS2 g++. Bump the `psxrecomp` gitlink when emitter/CLI behavior must change; leave it pinned for pure host/UI/`recomp-ui` releases so CI stays cmake-time. -## What players / RetComM do +## What players / Retro do | Step | When | |------|------| -| **Generate** (wizard or RetComM Build & Install) | First install, or after ROM/BIOS/emitter fingerprint changes | -| **Update** (RetComM) | New setup-host zip → refresh source → cmake rebuild; **codegen-cache** skips regenerate when fingerprints match | +| **Generate** (wizard or Retro Build & Install) | First install, or after ROM/BIOS/emitter fingerprint changes | +| **Update** (Retro) | New setup-host zip → refresh source → cmake rebuild; **codegen-cache** skips regenerate when fingerprints match | | **Generate & Rebuild** | Force disc→C again (`force_generate`) | Players do **not** need Generate & Rebuild for ordinary host/UI updates after the -first successful generate. RetComM keeps `apps//codegen-cache/` keyed by -ROM/BIOS + emitter fingerprints (see RetComM `docs/BUILD_PACKS.md`). +first successful generate. Retro keeps `apps/<title>/codegen-cache/` keyed by +ROM/BIOS + emitter fingerprints (see Retro `docs/BUILD_PACKS.md`). Raw zip extract is for true prebuilt Play binaries only. Setup-host catalog entries use local generate+cmake (`install_title_auto` / `update_title_auto`). diff --git a/docs/ci/README.md b/docs/ci/README.md index a498403a..54802c99 100644 --- a/docs/ci/README.md +++ b/docs/ci/README.md @@ -37,7 +37,7 @@ patch). Pass an explicit version to override. Shared helper: **Host-only model / player updates:** [`HOST_ONLY_RELEASES.md`](HOST_ONLY_RELEASES.md) -(CI never ships game C; RetComM Update reuses codegen-cache for host/UI bumps; +(CI never ships game C; Retro Update reuses codegen-cache for host/UI bumps; `reuse_cached_emitters` + ccache speed up release jobs when the `psxrecomp` pin is unchanged). diff --git a/docs/ci/templates/setup-release.yml b/docs/ci/templates/setup-release.yml index b6916125..41bedd46 100644 --- a/docs/ci/templates/setup-release.yml +++ b/docs/ci/templates/setup-release.yml @@ -21,7 +21,7 @@ # still loads the ICD dynamically via SDL. # # workflow_dispatch: leave version empty to auto-bump patch (or choose bump). -# Host-focused releases: CI never ships game C. Players Generate once; RetComM +# Host-focused releases: CI never ships game C. Players Generate once; Retro # Update reuses codegen-cache when ROM/BIOS/emitters match (cmake-only). # See docs/ci/HOST_ONLY_RELEASES.md. reuse_cached_emitters skips rebuilding # psxrecomp-game/bios when the psxrecomp SHA cache hits (host/UI bumps). @@ -554,7 +554,7 @@ jobs: export BPE_RUNTIME_BIN_DIR=/mingw64/bin fi export RELEASE_VERSION - # Lean zip (no embedded toolchain/). RetComM / wizard download + # Lean zip (no embedded toolchain/). Retro / wizard download # cmake-clang-v1; pass --embed-toolchain only for offline-first packs. scripts/package_setup_release.sh build-ci "${{ matrix.artifact }}" build-recompiler ls -la dist/ diff --git a/docs/config_schema.md b/docs/config_schema.md index 0454d4e1..c3d6c9e3 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -90,9 +90,9 @@ that is where the others go when a title needs them. Offline Play may still launch with a TOC warning; first-run setup Finish and online Create/Join require `netplay_ok` (and online also a clean verify + -non-empty `disc_fp`). Mirror `required_tracks` in the RetComM catalog as +non-empty `disc_fp`). Mirror `required_tracks` in the Retro catalog as `rom_identity.track_counts` so the hub library scan rejects Track-01-only dumps. -Wizard / RetComM / catalog submission accept Redump `.cue` + sibling `.bin` +Wizard / Retro / catalog submission accept Redump `.cue` + sibling `.bin` tracks only — not `.iso`/`.chd` (cannot reliably expand to multi-track). ## Program / game block diff --git a/host/psxrecomp_codegen_host.c b/host/psxrecomp_codegen_host.c index b24d28a8..ac9ca0ba 100644 --- a/host/psxrecomp_codegen_host.c +++ b/host/psxrecomp_codegen_host.c @@ -475,7 +475,7 @@ static int python_env_usable(const char* env) { return 1; } -/* Prefer portable pack CPython (RetComM / cmake-clang-v1), then system. */ +/* Prefer portable pack CPython (Retro / cmake-clang-v1), then system. */ static int find_python(char* out, size_t cap) { const char* env = getenv("RETCOMM_PYTHON"); if (python_env_usable(env)) { @@ -998,7 +998,7 @@ static int name_is_releases(const char* name) { #endif } -/* RetComM stages Play under apps/<title>/releases/<tag>/ while the generate +/* Retro stages Play under apps/<title>/releases/<tag>/ while the generate * tree lives at apps/<title>/src/current/. Walking parents of the release dir * never visits that sibling — probe it explicitly. */ static int try_retcomm_src_current(const char* start, char* out, size_t cap) { @@ -1905,7 +1905,7 @@ static int run_cli_posix(char* const argv[], /* ---- Host-native toolchain install (no Store Python AppData redirect) ---- */ -static const char* k_tc_repo = "TechnicallyComputers/retcomm-toolchains"; +static const char* k_tc_repo = "RetroPortingToolKit/RetroPorting-Toolchains"; static const char* toolchain_zip_asset_name(void) { #if defined(_WIN32) @@ -3412,7 +3412,7 @@ static int host_toolchain_update_available(char* local_ver, size_t local_cap, /* Download or offline-install cmake-clang-v1 (wizard page 0 / rebuild fallback). * Prefer host-native curl/tar so Microsoft Store Python cannot redirect the - * unpack into Packages\\...\\LocalCache. Installs into the shared RetComM + * unpack into Packages\\...\\LocalCache. Installs into the shared Retro * cache: %LOCALAPPDATA%/retcomm/toolchains/cmake-clang-v1/… * Broken latest/ stamps are healed, then GitHub /releases/latest is fetched. * @@ -3520,7 +3520,7 @@ static int host_ensure_toolchain(RecompLauncherCPrepareProgressFn on_progress, * disc.cfg is the mounted-image cache and the runtime takes only its first * line; the hot-swap roster is built from game.toml [game] discs. So the * wizard's picks reach the roster only by being written there -- which is - * exactly what the RetComM path does by running probe_disc.py per image and + * exactly what the Retro path does by running probe_disc.py per image and * verify_disc_set.py over the results. * * update_disc_set.py performs the same probe/verify and then edits ONLY the diff --git a/lib/retcomm-rbengine b/lib/retcomm-rbengine index a7b98507..ebd94a47 160000 --- a/lib/retcomm-rbengine +++ b/lib/retcomm-rbengine @@ -1 +1 @@ -Subproject commit a7b98507a62fe00e5aec3b90c52a4134f3c174bc +Subproject commit ebd94a4729abe2c0615070cef3ffe05b3f9ebf28 diff --git a/psxrecomp_cli.py b/psxrecomp_cli.py index 2339242f..d251f4c9 100755 --- a/psxrecomp_cli.py +++ b/psxrecomp_cli.py @@ -593,7 +593,7 @@ def _build_recompiler_targets( ) src = recompiler_source_dir(project_root) - # Prefer recompiler/build (packaging / RetComM harvest layout); also keep + # Prefer recompiler/build (packaging / Retro harvest layout); also keep # project-root build-recompiler if that is where prior binaries lived. build_dir = src / "build" try: @@ -755,7 +755,7 @@ def ensure_framework( ) -> Path: """Ensure project_root/psxrecomp has BIOS profiles + seeds for local generate. - GitHub zipballs omit git submodules, so RetComM source trees often lack + GitHub zipballs omit git submodules, so Retro source trees often lack psxrecomp/bios. Seed from the SDK pack that ships this CLI (ROOT). """ fw = project_root / "psxrecomp" @@ -780,7 +780,7 @@ def ensure_framework( marker = fw / ".gitignore" if not marker.is_file(): marker.write_text( - "# RetComM SDK seed marker (project-root for psxrecomp-bios)\n", + "# Retro SDK seed marker (project-root for psxrecomp-bios)\n", encoding="utf-8", ) return framework_root(project_root) @@ -1765,7 +1765,7 @@ def cmd_pgo_train(args: argparse.Namespace, progress: ProgressReporter) -> int: def cmd_ensure_toolchain(args: argparse.Namespace, progress: ProgressReporter) -> int: - """Resolve / download / unpack cmake-clang-v1 into the shared RetComM cache.""" + """Resolve / download / unpack cmake-clang-v1 into the shared Retro cache.""" project_root = ( Path(args.project_root).expanduser().resolve() if args.project_root diff --git a/recompiler/src/analysis_export.h b/recompiler/src/analysis_export.h index c943ada9..eec14484 100644 --- a/recompiler/src/analysis_export.h +++ b/recompiler/src/analysis_export.h @@ -1,7 +1,7 @@ #pragma once // analysis_export.h — writers that turn an AnalysisDb into artifacts. // -// The JSON files are the stable public interface: RetComM Studio and any other +// The JSON files are the stable public interface: Retro Studio and any other // front end consume those, never the analyzer's in-process types. Everything // else here exists to hand the data to tools people already use rather than to // make them adopt a new one. diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index c15f32d4..e83b446a 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -2309,7 +2309,7 @@ EmitStats FullFunctionEmitter::emit( { EmitStats stats; - // Setup / RetComM zips omit generated/; create it before ofstream. + // Setup / Retro zips omit generated/; create it before ofstream. if (!out_dir.empty()) { std::error_code ec; std::filesystem::create_directories(out_dir, ec); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 59edc928..3c93395e 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -31,7 +31,7 @@ endif() # SOURCE + compiler + flags (content, not mtime), so those recompiles collapse to # near-instant cache hits after any branch op. Completely no-op when ccache is not # on PATH, so builds still work without it. Set once, before any target is added. -# RetComM cmake-clang-v1 packs ship bin/ccache and prepend that dir to PATH; +# Retro cmake-clang-v1 packs ship bin/ccache and prepend that dir to PATH; # also HINT RETCOMM_TOOLCHAIN_DIR for wizards that only set the env override. if(NOT DEFINED CMAKE_C_COMPILER_LAUNCHER) set(_psx_ccache_hints "") @@ -103,7 +103,7 @@ set(PSX_SDL_LIBRARIES "") set(PSX_SDL_STATIC_LDFLAGS "") set(PSX_SDL3 OFF) -# Portable cmake-clang-v1 pack roots (wizard / RetComM / CI emitter fetch). +# Portable cmake-clang-v1 pack roots (wizard / Retro / CI emitter fetch). # Collect as HINTS only — never list(PREPEND CMAKE_PREFIX_PATH …): on Windows CI # the pack is llvm-mingw while the setup host links with MSYS2 g++, and a # global prefix puts pack lib/ on the -L path so -static-libstdc++ can pick up @@ -1447,7 +1447,7 @@ function(psxrecomp_add_runtime_target target) # ---- Windows / desktop app icon --------------------------------------- # Prefer an explicit APP_ICON, then the game-repo copy under assets/, then - # the framework default shipped in psxrecomp/assets (RetComM-themed pad). + # the framework default shipped in psxrecomp/assets (Retro-themed pad). if(NOT PSXRT_APP_ICON) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico") set(PSXRT_APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico") diff --git a/tools/duckstation/README.md b/tools/duckstation/README.md index fa92cb7a..e7ee4a38 100644 --- a/tools/duckstation/README.md +++ b/tools/duckstation/README.md @@ -13,7 +13,7 @@ PSXRecomp v4 uses a patched build of [stenzek/duckstation](https://github.com/st ## First-time setup -- Linux / macOS Use `tools/duckstation_oracle.py`. It installs outside any game repo, into the -shared RetComM data root, so one build serves every title: +shared Retro data root, so one build serves every title: ```text ~/.local/share/retcomm/oracle/duckstation/ ($RETCOMM_DATA_DIR, or $RETCOMM_ORACLE_DIR) diff --git a/tools/duckstation_oracle.py b/tools/duckstation_oracle.py index 1fef9b44..415bc9e7 100644 --- a/tools/duckstation_oracle.py +++ b/tools/duckstation_oracle.py @@ -18,7 +18,7 @@ bracket where. Where it lives -------------- NOT in the game repo. The oracle is a developer tool shared by every recomp -title, so it installs into the RetComM data root alongside the toolchains and +title, so it installs into the Retro data root alongside the toolchains and catalog: ~/.local/share/retcomm/oracle/duckstation/ (Linux / macOS) @@ -26,7 +26,7 @@ catalog: %LOCALAPPDATA%\\retcomm\\oracle\\duckstation\\ (Windows fallback) Override with RETCOMM_ORACLE_DIR, or move the whole root with RETCOMM_DATA_DIR -(the same variables RetComM Launcher and Studio already honour). +(the same variables Retro Launcher and Studio already honour). <root>/src/ pinned upstream checkout with the oracle patch applied <root>/build/ cmake/ninja tree @@ -160,7 +160,7 @@ def log(msg: str) -> None: # --------------------------------------------------------------------------- def data_root() -> Path: - """The RetComM shared data root — same one the launcher and Studio use. + """The Retro shared data root — same one the launcher and Studio use. The precedence must match studio_runner.cpp's retcomm_data_dir() exactly, XDG_DATA_HOME included. If the two disagree, the GUI reports an oracle @@ -656,7 +656,7 @@ def cmd_build(args: argparse.Namespace) -> int: def find_bios(explicit: Optional[str]) -> Optional[Path]: """Locate a retail BIOS image for the oracle. - Order: what the caller passed, then the RetComM BIOS root, then the engine + Order: what the caller passed, then the Retro BIOS root, then the engine checkout this script lives in. Never downloads one — a BIOS dump is the user's own property and is not redistributable. """ @@ -1209,7 +1209,7 @@ def main(argv=None) -> int: p = sub.add_parser("status", help="where it is and what state it is in") p.add_argument("--json", action="store_true", - help="machine-readable, for RetComM Studio") + help="machine-readable, for Retro Studio") p.set_defaults(func=cmd_status) p = sub.add_parser("path", help="print the install root") diff --git a/tools/fetch_toolchain.sh b/tools/fetch_toolchain.sh index f04375a2..f42cc52b 100755 --- a/tools/fetch_toolchain.sh +++ b/tools/fetch_toolchain.sh @@ -18,7 +18,7 @@ set -euo pipefail ARTIFACT="" DL_DIR=".cache/toolchain-dl" OUT_DIR=".cache/toolchain-pack" -REPO="TechnicallyComputers/retcomm-toolchains" +REPO="RetroPortingToolKit/RetroPorting-Toolchains" usage() { sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' diff --git a/tools/gpu_frame_capture.py b/tools/gpu_frame_capture.py index 7cd08303..1818eefb 100644 --- a/tools/gpu_frame_capture.py +++ b/tools/gpu_frame_capture.py @@ -21,7 +21,7 @@ freezing forwards, and it is the only thing that works for a glitch you cannot stop on. Writes <out>/<tag>.json (a psx-gpu-frame dump), <out>/<tag>.summary.json (the -compact attribution RetComM Studio reads), <out>/<tag>.png (the presented +compact attribution Retro Studio reads), <out>/<tag>.png (the presented frame), and <out>/<tag>.opcodes.json, so a later diff or layer render needs nothing still running. """ diff --git a/tools/gpu_parity.py b/tools/gpu_parity.py index 38057942..39abead4 100644 --- a/tools/gpu_parity.py +++ b/tools/gpu_parity.py @@ -27,7 +27,7 @@ there is nothing to do but restart the DuckStation side; that is reported rather than papered over. Getting the oracle: `python3 duckstation_oracle.py all` builds and installs a -patched DuckStation into the RetComM data root (once, ~10 minutes), and +patched DuckStation into the Retro data root (once, ~10 minutes), and `duckstation_oracle.py start --disc <cue>` runs it headless on 4371. Pass --start-oracle here to have this tool do that for you when nothing is listening. diff --git a/tools/new_project_layout/fill_tokens.py b/tools/new_project_layout/fill_tokens.py index 43e6c048..76c0a5d0 100755 --- a/tools/new_project_layout/fill_tokens.py +++ b/tools/new_project_layout/fill_tokens.py @@ -42,7 +42,7 @@ def install_dir_name(name: str) -> str: """Local checkout / launcher ``apps/`` folder — same slug as the GitHub repo name. New-project wizard must create this folder (not the display name with spaces) - so Studio catalog matching and RetComM ``install_dir_name`` stay consistent. + so Studio catalog matching and Retro ``install_dir_name`` stay consistent. """ return sanitize_github_name(name) diff --git a/tools/new_project_layout/probe_disc.py b/tools/new_project_layout/probe_disc.py index 959074b4..6cd2a04a 100755 --- a/tools/new_project_layout/probe_disc.py +++ b/tools/new_project_layout/probe_disc.py @@ -651,7 +651,7 @@ def main() -> int: ap.add_argument( "--write-catalog", default="", - help="write catalog_identity.json for RetComM / submission autofill", + help="write catalog_identity.json for Retro / submission autofill", ) ap.add_argument( "--write-seeds", diff --git a/tools/new_project_layout/project_studio/cli.py b/tools/new_project_layout/project_studio/cli.py index 934296ee..15281004 100644 --- a/tools/new_project_layout/project_studio/cli.py +++ b/tools/new_project_layout/project_studio/cli.py @@ -168,13 +168,13 @@ def cmd_gui(args: argparse.Namespace) -> int: # tools/new_project_layout/project_studio/cli.py → repo root repo = here.parents[3] if len(here.parents) > 3 else here.parents[2] for rel in ( - Path("build") / "RetComM-Studio", + Path("build") / "Retro-Studio", Path("build") / "retcomm-studio", - Path("build-release") / "RetComM-Studio", - Path("out") / "bin" / "RetComM-Studio", + Path("build-release") / "Retro-Studio", + Path("out") / "bin" / "Retro-Studio", ): candidates.append(repo / rel) - which = shutil.which("RetComM-Studio") or shutil.which("retcomm-studio") + which = shutil.which("Retro-Studio") or shutil.which("retcomm-studio") if which: candidates.append(Path(which)) @@ -186,10 +186,10 @@ def cmd_gui(args: argparse.Namespace) -> int: return int(subprocess.call(cmd)) print( - "RetComM Studio GUI is Dear ImGui (native).\n" + "Retro Studio GUI is Dear ImGui (native).\n" "Build it first:\n" " cmake -S . -B build && cmake --build build\n" - " ./build/RetComM-Studio\n" + " ./build/Retro-Studio\n" "Or set RETCOMM_STUDIO_BIN to the executable path.", file=sys.stderr, ) diff --git a/tools/new_project_layout/project_studio/detect.py b/tools/new_project_layout/project_studio/detect.py index 55cada0b..e4d4063d 100644 --- a/tools/new_project_layout/project_studio/detect.py +++ b/tools/new_project_layout/project_studio/detect.py @@ -740,7 +740,7 @@ def audit_project(root: Path) -> AuditReport: title="Disc / catalog identity", status=CheckStatus.WARN, severity=Severity.RECOMMENDED, - detail="No catalog_identity.json / [prepare_disc] — needed for RetComM gates.", + detail="No catalog_identity.json / [prepare_disc] — needed for Retro gates.", fix_op="probe_disc_refresh", ) ) @@ -785,7 +785,7 @@ def audit_project(root: Path) -> AuditReport: ) ) - # Default RetComM-themed app icon (Windows .ico + PNG) + # Default Retro-themed app icon (Windows .ico + PNG) app_ico = root / "assets" / "psxrecomp.ico" if app_ico.is_file(): checks.append( @@ -804,7 +804,7 @@ def audit_project(root: Path) -> AuditReport: title="assets/psxrecomp app icon", status=CheckStatus.WARN, severity=Severity.RECOMMENDED, - detail="Missing assets/psxrecomp.ico (RetComM-themed default pad icon).", + detail="Missing assets/psxrecomp.ico (Retro-themed default pad icon).", fix_op="ensure_app_icon", ) ) @@ -895,7 +895,7 @@ def audit_project(root: Path) -> AuditReport: ) ) - # README download badges + boxart + RetComM Launcher (idempotent migrate op) + # README download badges + boxart + Retro Launcher (idempotent migrate op) readme_path = root / "README.md" readme_text = _read(readme_path) if readme_path.is_file() else "" missing_readme: list[str] = [] @@ -907,7 +907,7 @@ def audit_project(root: Path) -> AuditReport: if not readme_has_boxart(readme_text): missing_readme.append("libretro boxart") if not readme_has_launcher(readme_text): - missing_readme.append("RetComM Launcher section") + missing_readme.append("Retro Launcher section") if not readme_has_raid(readme_text): missing_readme.append("R.A.I.D. Discord footer") if not (root / ".github" / "raid-discord.png").is_file(): @@ -932,7 +932,7 @@ def audit_project(root: Path) -> AuditReport: title="README download metrics / launcher / RAID / boxart", status=CheckStatus.PASS, severity=Severity.RECOMMENDED, - detail="Download badges, boxart, RetComM Launcher, and R.A.I.D. footer present.", + detail="Download badges, boxart, Retro Launcher, and R.A.I.D. footer present.", ) ) diff --git a/tools/new_project_layout/project_studio/gitops.py b/tools/new_project_layout/project_studio/gitops.py index 821ddef9..8e31f29b 100644 --- a/tools/new_project_layout/project_studio/gitops.py +++ b/tools/new_project_layout/project_studio/gitops.py @@ -16,9 +16,9 @@ from pathlib import Path from typing import Any DEFAULT_PSXRECOMP_URL = "https://github.com/mstan/psxrecomp.git" -DEFAULT_RECOMP_UI_URL = "https://github.com/mstan/recomp-ui.git" -DEFAULT_RECOMP_NET_URL = "https://github.com/TechnicallyComputers/recomp-net.git" -DEFAULT_RBENGINE_URL = "https://github.com/TechnicallyComputers/retcomm-rbengine.git" +DEFAULT_RECOMP_UI_URL = "https://github.com/RetroPortingToolKit/recomp-ui.git" +DEFAULT_RECOMP_NET_URL = "https://github.com/RetroPortingToolKit/recomp-net.git" +DEFAULT_RBENGINE_URL = "https://github.com/RetroPortingToolKit/rbengine.git" DEFAULT_BRANCH = "master" DEFAULT_NESTED_BRANCH = "main" KNOWN_SUBMODULES = ("psxrecomp", "recomp-ui") diff --git a/tools/new_project_layout/project_studio/gui.py b/tools/new_project_layout/project_studio/gui.py index 58029c24..ce3d266e 100644 --- a/tools/new_project_layout/project_studio/gui.py +++ b/tools/new_project_layout/project_studio/gui.py @@ -294,7 +294,7 @@ def _ensure_gui_deps() -> str | None: if _ctk_importable(): return None return ( - "Frozen RetComM Studio build is missing customtkinter.\n" + "Frozen Retro Studio build is missing customtkinter.\n" "Reinstall from a release package, or run from source." ) @@ -376,7 +376,7 @@ class ProjectStudioApp: def __init__(self, ctk, *, initial_root: Path | None = None) -> None: self.ctk = ctk self.root = ctk.CTk() - self.root.title("RetComM Studio") + self.root.title("Retro Studio") self.root.geometry("1100x820") self.root.minsize(900, 640) # Maximize after the first map so WMs honor zoomed/fullscreen hints. @@ -518,7 +518,7 @@ class ProjectStudioApp: header.pack(fill="x", padx=16, pady=(16, 8)) ctk.CTkLabel( header, - text="RetComM Studio", + text="Retro Studio", font=ctk.CTkFont(size=22, weight="bold"), ).pack(side="left") ctk.CTkLabel( @@ -1123,7 +1123,7 @@ class ProjectStudioApp: self._prompt_apply_updates(result) elif not silent_if_current: messagebox.showinfo( - "RetComM Studio", + "Retro Studio", result.message or "Everything is up to date.", parent=self.root, ) @@ -1146,10 +1146,10 @@ class ProjectStudioApp: "Updates are available:\n\n" + "\n".join(lines) + "\n\nApply now?\n" - "(Toolchain installs into the shared RetComM cache used by the " + "(Toolchain installs into the shared Retro cache used by the " "launcher and game apps.)" ) - if not messagebox.askyesno("RetComM Studio", body, parent=self.root): + if not messagebox.askyesno("Retro Studio", body, parent=self.root): return update_studio = bool(result.studio.available) @@ -1177,14 +1177,14 @@ class ProjectStudioApp: self._update_status_var.set("Update done" if not should_exit else "Restarting…") if should_exit: messagebox.showinfo( - "RetComM Studio", + "Retro Studio", summary + "\n\nStudio will exit to finish the update.", parent=self.root, ) self.root.after(300, self.root.destroy) else: messagebox.showinfo( - "RetComM Studio", summary, parent=self.root + "Retro Studio", summary, parent=self.root ) self.root.after(0, done) diff --git a/tools/new_project_layout/project_studio/naming.py b/tools/new_project_layout/project_studio/naming.py index a5e2a0e5..82c9c638 100644 --- a/tools/new_project_layout/project_studio/naming.py +++ b/tools/new_project_layout/project_studio/naming.py @@ -297,7 +297,7 @@ def build_token_map( "EXE_BASENAME": exe_basename(title), "ZIP_PREFIX": zp, "GITHUB_OWNER": sanitize_github_name( - (github_owner or "").strip() or "TechnicallyComputers" + (github_owner or "").strip() or "RetroPortingToolKit" ), "GITHUB_REPO": sanitize_github_name((github_repo or "").strip() or name), "DISC_HINT": f"your legally owned {game} disc", diff --git a/tools/new_project_layout/project_studio/ops.py b/tools/new_project_layout/project_studio/ops.py index fe2cd43e..ed4c00a6 100644 --- a/tools/new_project_layout/project_studio/ops.py +++ b/tools/new_project_layout/project_studio/ops.py @@ -1259,7 +1259,7 @@ def _ensure_boxart_png(root: Path, options: MigrateOptions) -> list[str]: def op_patch_readme_metrics(root: Path, options: MigrateOptions) -> ApplyResult: - """Upsert download badges, libretro boxart, RetComM Launcher, and R.A.I.D. footer.""" + """Upsert download badges, libretro boxart, Retro Launcher, and R.A.I.D. footer.""" from .readme_metrics import ( apply_github_about, boxart_png_present, @@ -1313,7 +1313,7 @@ def op_patch_readme_metrics(root: Path, options: MigrateOptions) -> ApplyResult: parts: list[str] = [] if changed: parts.append( - f"Patched README badges, boxart, RetComM Launcher, and R.A.I.D. footer ({owner}/{repo})" + f"Patched README badges, boxart, Retro Launcher, and R.A.I.D. footer ({owner}/{repo})" ) if fetched: parts.append("fetched libretro boxart") diff --git a/tools/new_project_layout/project_studio/plan.py b/tools/new_project_layout/project_studio/plan.py index 6c46e5e4..000da746 100644 --- a/tools/new_project_layout/project_studio/plan.py +++ b/tools/new_project_layout/project_studio/plan.py @@ -54,7 +54,7 @@ _OP_TITLES = { "annotate_legacy_packaging": "Annotate legacy prebuilt packaging", "probe_disc_refresh": "Refresh disc identity via probe_disc.py", "record_framework_pins": "Write framework_pins.txt", - "patch_readme_metrics": "Patch README badges, RetComM Launcher, and R.A.I.D. footer", + "patch_readme_metrics": "Patch README badges, Retro Launcher, and R.A.I.D. footer", } diff --git a/tools/new_project_layout/project_studio/readme_metrics.py b/tools/new_project_layout/project_studio/readme_metrics.py index bf771a50..09c7a38c 100644 --- a/tools/new_project_layout/project_studio/readme_metrics.py +++ b/tools/new_project_layout/project_studio/readme_metrics.py @@ -1,4 +1,4 @@ -"""README download-badge + RetComM Launcher blocks for scaffold and migrate.""" +"""README download-badge + Retro Launcher blocks for scaffold and migrate.""" from __future__ import annotations @@ -40,8 +40,11 @@ _RAID_RE = re.compile( re.escape(RAID_BEGIN) + r".*?" + re.escape(RAID_END) + r"\n?", re.S, ) +# Both headings: this section was "## RetComM Launcher" until the rename, and +# every README generated before it still says so. Matching only the new one +# would leave the old block in place and append a second one beside it. _LEGACY_LAUNCHER_RE = re.compile( - r"^## RetComM Launcher\n.*?(?=^## |\Z)", + r"^## (?:Retro|RetComM) Launcher\n.*?(?=^## |\Z)", re.M | re.S, ) _LEGACY_RAID_RE = re.compile( @@ -53,8 +56,8 @@ _REMOTE_RE = re.compile( re.I, ) -DEFAULT_GITHUB_OWNER = "TechnicallyComputers" -LAUNCHER_REPO = "https://github.com/TechnicallyComputers/RetComM-Launcher" +DEFAULT_GITHUB_OWNER = "RetroPortingToolKit" +LAUNCHER_REPO = "https://github.com/RetroPortingToolKit/Retro-Launcher" def parse_github_remote(url: str) -> tuple[str, str] | None: @@ -185,33 +188,35 @@ def render_boxart_block(game_name: str) -> str: def render_launcher_block() -> str: - shots = ( - "https://raw.githubusercontent.com/TechnicallyComputers/" - "RetComM-Launcher/main/docs/screenshots" - ) + # Derived from LAUNCHER_REPO rather than spelled out again: this was a + # second copy of the slug, split across two string literals, and the org + # rename swept past it — every regenerated README kept the dead owner. + shots = LAUNCHER_REPO.replace( + "https://github.com/", "https://raw.githubusercontent.com/" + ) + "/main/docs/screenshots" return "\n".join( [ LAUNCHER_BEGIN, - "## RetComM Launcher", + "## Retro Launcher", "", "You can run this title **standalone** (release zip + the built-in recomp-ui", "Generate & Build flow), or manage installs, updates, ROM/BIOS wiring, and queued", "builds more intuitively with", - f"**[RetComM Launcher]({LAUNCHER_REPO})** —", + f"**[Retro Launcher]({LAUNCHER_REPO})** —", "the Retro Compilation Manager hub for self-compiling recomps.", "", f"[Downloads]({LAUNCHER_REPO}/releases) ·", f"[Full README & features]({LAUNCHER_REPO}#readme)", "", "<p align=\"center\">", - f' <img src="{shots}/hub-and-game-launcher.png" alt="RetComM hub with a background build, next to a title’s recomp-ui launcher" width="720">', + f' <img src="{shots}/hub-and-game-launcher.png" alt="Retro hub with a background build, next to a title’s recomp-ui launcher" width="720">', "</p>", "", "<p align=\"center\">", f' <img src="{shots}/queue-and-background-build.png" alt="Background cmake build with titles queued" width="720">', "</p>", "", - "RetComM checks for updates, rebuilds with existing build data when possible,", + "Retro checks for updates, rebuilds with existing build data when possible,", "shares the portable toolchain used by per-title launchers, and automates", "BIOS/ROM/save plumbing so you are not stuck repeating each game’s wizard by hand.", LAUNCHER_END, @@ -250,7 +255,17 @@ def boxart_png_present(root: Path) -> bool: def readme_has_launcher(text: str) -> bool: - return "TechnicallyComputers/RetComM-Launcher" in text + # Both slugs: every port generated before the RetroPortingToolKit transfer + # carries the old one, and matching only the new one would report each of + # them as missing its launcher section — and append a second copy to any + # README that has neither the markers nor a "## Retro Launcher" heading. + return any( + slug in text + for slug in ( + "RetroPortingToolKit/Retro-Launcher", + "TechnicallyComputers/RetComM-Launcher", + ) + ) def readme_has_raid(text: str) -> bool: diff --git a/tools/new_project_layout/setup_project.ps1 b/tools/new_project_layout/setup_project.ps1 index 051cf695..c0b1d87e 100644 --- a/tools/new_project_layout/setup_project.ps1 +++ b/tools/new_project_layout/setup_project.ps1 @@ -146,9 +146,9 @@ if (-not $ZipPrefix) { if (-not $GithubOwner) { if ($interactive) { - $GithubOwner = Prompt-Line "GitHub owner / org (README download badges)" "TechnicallyComputers" + $GithubOwner = Prompt-Line "GitHub owner / org (README download badges)" "RetroPortingToolKit" } else { - $GithubOwner = "TechnicallyComputers" + $GithubOwner = "RetroPortingToolKit" } } $derivedRepo = (python -c "from fill_tokens import sanitize_github_name; import sys; print(sanitize_github_name(sys.argv[1]))" $Name).Trim() @@ -293,7 +293,7 @@ set(PSX_RECOMP_UI OFF CACHE BOOL if ($useNetplay) { # PSX_NETPLAY defaults RNET_ENABLE_ICE=ON; recomp-net FetchContents - # libjuice via pinned URL (not git) so RetComM AppImage builds configure. + # libjuice via pinned URL (not git) so Retro AppImage builds configure. Set-Content -Encoding UTF8 -Path $NetplayBlockFile -Value @" if(EXISTS "`${PSXRECOMP_ROOT}/lib/recomp-net/CMakeLists.txt") set(PSX_NETPLAY ON CACHE BOOL @@ -430,7 +430,7 @@ if ($useRecompUi) { } git submodule update --init --recursive -# RetComM-themed default app icon (Windows .ico + PNG for packaging). +# Retro-themed default app icon (Windows .ico + PNG for packaging). $iconSrc = Join-Path $Root "psxrecomp\assets" $iconDst = Join-Path $Root "assets" if (Test-Path $iconSrc) { diff --git a/tools/new_project_layout/setup_project.sh b/tools/new_project_layout/setup_project.sh index 8011a311..5c4d7efa 100755 --- a/tools/new_project_layout/setup_project.sh +++ b/tools/new_project_layout/setup_project.sh @@ -9,7 +9,7 @@ # --stage-disc Copy full cue+bins into repo disc/ (large; optional) # --no-stage-disc Default: probe in place, extract boot EXE only # --psxrecomp-ref / --recomp-ui-ref / --recomp-net-ref / URLs -# --github-owner <org> README download-badge owner (default TechnicallyComputers) +# --github-owner <org> README download-badge owner (default RetroPortingToolKit) # --github-repo <name> README download-badge repo (default project name) # # Everything else is prompted on a TTY (or passed via flags / --yes defaults): @@ -272,10 +272,10 @@ fi if [ -z "$GITHUB_OWNER" ]; then if [ "$YES_MODE" -eq 1 ] || ! is_tty; then - GITHUB_OWNER=TechnicallyComputers + GITHUB_OWNER=RetroPortingToolKit else prompt_line "GitHub owner / org (README download badges)" GITHUB_OWNER \ - "TechnicallyComputers" + "RetroPortingToolKit" fi fi if [ -z "$GITHUB_REPO" ]; then @@ -512,7 +512,7 @@ fi if [ "$ENABLE_NETPLAY" -eq 1 ]; then # PSX_NETPLAY defaults RNET_ENABLE_ICE=ON; recomp-net FetchContents - # libjuice via pinned URL (not git) so RetComM AppImage builds configure. + # libjuice via pinned URL (not git) so Retro AppImage builds configure. printf '%s\n' \ 'if(EXISTS "${PSXRECOMP_ROOT}/lib/recomp-net/CMakeLists.txt") set(PSX_NETPLAY ON CACHE BOOL @@ -651,7 +651,7 @@ if [ "$ENABLE_RECOMP_UI" -eq 1 ]; then fi git submodule update --init --recursive -# RetComM-themed default app icon (Windows .ico + PNG for packaging). +# Retro-themed default app icon (Windows .ico + PNG for packaging). if [ -d psxrecomp/assets ]; then mkdir -p "$ROOT/assets" for _icon in psxrecomp.svg psxrecomp.png psxrecomp.ico; do diff --git a/tools/new_project_layout/templates/README.md.in b/tools/new_project_layout/templates/README.md.in index 3f31de03..cafc39da 100644 --- a/tools/new_project_layout/templates/README.md.in +++ b/tools/new_project_layout/templates/README.md.in @@ -23,26 +23,26 @@ Scaffolded with the New Project Layout. See `psxrecomp/docs/GAME_PROJECT_SETUP.md` for the full flow. <!-- retcomm-readme-launcher --> -## RetComM Launcher +## Retro Launcher You can run this title **standalone** (release zip + the built-in recomp-ui Generate & Build flow), or manage installs, updates, ROM/BIOS wiring, and queued builds more intuitively with -**[RetComM Launcher](https://github.com/TechnicallyComputers/RetComM-Launcher)** — +**[Retro Launcher](https://github.com/RetroPortingToolKit/Retro-Launcher)** — the Retro Compilation Manager hub for self-compiling recomps. -[Downloads](https://github.com/TechnicallyComputers/RetComM-Launcher/releases) · -[Full README & features](https://github.com/TechnicallyComputers/RetComM-Launcher#readme) +[Downloads](https://github.com/RetroPortingToolKit/Retro-Launcher/releases) · +[Full README & features](https://github.com/RetroPortingToolKit/Retro-Launcher#readme) <p align="center"> - <img src="https://raw.githubusercontent.com/TechnicallyComputers/RetComM-Launcher/main/docs/screenshots/hub-and-game-launcher.png" alt="RetComM hub with a background build, next to a title’s recomp-ui launcher" width="720"> + <img src="https://raw.githubusercontent.com/RetroPortingToolKit/Retro-Launcher/main/docs/screenshots/hub-and-game-launcher.png" alt="Retro hub with a background build, next to a title’s recomp-ui launcher" width="720"> </p> <p align="center"> - <img src="https://raw.githubusercontent.com/TechnicallyComputers/RetComM-Launcher/main/docs/screenshots/queue-and-background-build.png" alt="Background cmake build with titles queued" width="720"> + <img src="https://raw.githubusercontent.com/RetroPortingToolKit/Retro-Launcher/main/docs/screenshots/queue-and-background-build.png" alt="Background cmake build with titles queued" width="720"> </p> -RetComM checks for updates, rebuilds with existing build data when possible, +Retro checks for updates, rebuilds with existing build data when possible, shares the portable toolchain used by per-title launchers, and automates BIOS/ROM/save plumbing so you are not stuck repeating each game’s wizard by hand. <!-- /retcomm-readme-launcher --> @@ -53,7 +53,7 @@ You must own the original game. Disc images under `disc/` are gitignored and must never be committed. Retail BIOS dumps are not redistributed; OpenBIOS is used for Generate unless you supply your own SCPH locally. -Default app icon: `assets/psxrecomp.ico` (and `.png` / `.svg`) — RetComM-themed controller mark from `psxrecomp/assets/`. Windows builds embed it via `APP_ICON`. +Default app icon: `assets/psxrecomp.ico` (and `.png` / `.svg`) — Retro-themed controller mark from `psxrecomp/assets/`. Windows builds embed it via `APP_ICON`. Optional box art under `launcher_assets/img/` may come from [libretro-thumbnails](https://github.com/libretro-thumbnails/libretro-thumbnails) diff --git a/tools/new_project_layout/update_disc_set.py b/tools/new_project_layout/update_disc_set.py index d674f26a..92156233 100755 --- a/tools/new_project_layout/update_disc_set.py +++ b/tools/new_project_layout/update_disc_set.py @@ -17,7 +17,7 @@ player located and update only the keys that describe the set: Everything else in the file is preserved byte-for-byte, comments included. This is the step that makes the standalone wizard reach the same end state as -the RetComM path, which runs probe_disc.py per disc and verify_disc_set.py over +the Retro path, which runs probe_disc.py per disc and verify_disc_set.py over the results. The runtime's hot-swap roster is built from [game] discs -- not from disc.cfg, which is only the mounted-image cache -- so without this the wizard can record every disc a player owns and none of them reach the roster. @@ -212,7 +212,7 @@ def main() -> int: print(f"error: missing disc image {c}", file=sys.stderr) return 1 - # Probe each disc, writing the same per-disc artifacts the RetComM path + # Probe each disc, writing the same per-disc artifacts the Retro path # leaves behind (disc_probe.json, disc_probe.2.json, ...). probes, probe_jsons = [], [] for i, cue in enumerate(cues, start=1): diff --git a/tools/package_setup_host.sh b/tools/package_setup_host.sh index 19dbd8c3..7482e9fa 100755 --- a/tools/package_setup_host.sh +++ b/tools/package_setup_host.sh @@ -3,7 +3,7 @@ # # Stages the host exe, title sources, filtered psxrecomp/ + recomp-ui/, then # finishes with stage_setup_sdk.sh (emitters, OpenBIOS, MinGW DLLs). -# Portable cmake/clang is NOT embedded by default — RetComM / the setup wizard +# Portable cmake/clang is NOT embedded by default — Retro / the setup wizard # download cmake-clang-v1 from retcomm-toolchains (or accept an offline zip). # # Usage (from game repo root): @@ -262,7 +262,7 @@ ZIP_NAME="${ZIP_PREFIX}-${VERSION}-${ARTIFACT}.zip" rm -f "${DIST}/${ZIP_NAME}" cp -a "${EXE}" "${STAGE}/" -# Ship the stamp beside the exe so installers / RetComM can prefer it over VERSION. +# Ship the stamp beside the exe so installers / Retro can prefer it over VERSION. if [[ -f "$(dirname "${EXE}")/psx_game_version.txt" ]]; then cp -a "$(dirname "${EXE}")/psx_game_version.txt" "${STAGE}/psx_game_version.txt" else @@ -498,11 +498,11 @@ Standalone: 3. Provide ${DISC_HINT} (and optional retail SCPH-1001 BIOS; otherwise OpenBIOS is regenerated locally). 4. Follow the Generate & rebuild wizard. On first rebuild the host downloads - cmake-clang-v1 from TechnicallyComputers/retcomm-toolchains (or you can + cmake-clang-v1 from RetroPortingToolKit/RetroPorting-Toolchains (or you can pick a local cmake-clang-v1-*.zip for offline builds). System cmake/ninja also works if already on PATH. -RetComM uses this same zip: it harvests emitters into a shared SDK cache, +Retro uses this same zip: it harvests emitters into a shared SDK cache, downloads the toolchain pack (or uses RETCOMM_TOOLCHAIN_DIR), and preserves saves/user config across updates. EOF diff --git a/tools/prepare_disc.py b/tools/prepare_disc.py index ea1e62fe..40a7aa72 100644 --- a/tools/prepare_disc.py +++ b/tools/prepare_disc.py @@ -13,7 +13,7 @@ Accepted inputs: Writes working ``.bin`` + ``.cue`` (or copies a multi-track Redump set), extracts ``SYSTEM.CNF`` + boot EXE, and prints ``RESULT_CUE=<abs path>`` -for the first-run wizard / RetComM. +for the first-run wizard / Retro. ISO→2352 sets Mode2 Form1 sync/header/subheader/EDC (ECC zeroed — fine for software readers). Rebuilt images are not bit-identical to Redump. diff --git a/tools/psx_gpu_frame.py b/tools/psx_gpu_frame.py index 2fcb7a9e..cb40219f 100644 --- a/tools/psx_gpu_frame.py +++ b/tools/psx_gpu_frame.py @@ -674,7 +674,7 @@ def summarise_dump(dump: Dict[str, Any], dump_name: str = "") -> Dict[str, Any]: A busy frame's full dump runs to tens of megabytes. Nothing that only wants to know *who drew what* should have to parse that, so the summary is a - separate artifact and RetComM Studio reads this rather than the dump. + separate artifact and Retro Studio reads this rather than the dump. """ ops: Dict[str, int] = {} modes: Dict[str, int] = {} diff --git a/tools/stage_setup_sdk.sh b/tools/stage_setup_sdk.sh index 4dd91b85..2a739dee 100755 --- a/tools/stage_setup_sdk.sh +++ b/tools/stage_setup_sdk.sh @@ -3,7 +3,7 @@ # into an existing setup-host release stage directory. # # Title packagers copy the host exe, game sources, and framework tree first, -# then call this to finish the RetComM/wizard-complete zip layout. +# then call this to finish the Retro/wizard-complete zip layout. # # Usage: # stage_setup_sdk.sh --stage <stage-dir> [options] @@ -235,7 +235,7 @@ if [[ -n "${TOOLCHAIN_DIR}" && -d "${TOOLCHAIN_DIR}" ]]; then fi echo "bundled toolchain from ${TOOLCHAIN_DIR}" elif [[ "${ALLOW_NO_TOOLCHAIN}" -eq 1 ]]; then - echo "note: no embedded toolchain/ — RetComM/wizard will download cmake-clang-v1" \ + echo "note: no embedded toolchain/ — Retro/wizard will download cmake-clang-v1" \ "(or accept an offline zip / system cmake)" >&2 else echo "error: toolchain dir required (pass --toolchain-dir or set PSXRECOMP_TOOLCHAIN_DIR)" >&2 diff --git a/tools/tests/test_duckstation_oracle.py b/tools/tests/test_duckstation_oracle.py index 67e73618..a1435e46 100644 --- a/tools/tests/test_duckstation_oracle.py +++ b/tools/tests/test_duckstation_oracle.py @@ -193,7 +193,7 @@ class TestSettingsMerge(unittest.TestCase): class TestStatusDoc(EnvGuard): - """The status document is the whole contract with RetComM Studio.""" + """The status document is the whole contract with Retro Studio.""" def test_absent_install_reports_absent(self): os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp() diff --git a/tools/toolchain_pack.py b/tools/toolchain_pack.py index 93d6780a..c2938cab 100644 --- a/tools/toolchain_pack.py +++ b/tools/toolchain_pack.py @@ -1,6 +1,6 @@ """Resolve / download / unpack portable cmake-clang-v1 toolchain packs. -Shared cache matches RetComM / retcomm-toolchains install.sh: +Shared cache matches Retro / retcomm-toolchains install.sh: Windows: %LOCALAPPDATA%/retcomm/toolchains/cmake-clang-v1/<tag>/ Linux/macOS: $XDG_DATA_HOME/retcomm/… and ~/.local/share/retcomm/… @@ -24,7 +24,7 @@ import zipfile from pathlib import Path from typing import Optional -DEFAULT_REPO = "TechnicallyComputers/retcomm-toolchains" +DEFAULT_REPO = "RetroPortingToolKit/RetroPorting-Toolchains" PACK_ID = "cmake-clang-v1" _ASSET = { @@ -260,7 +260,7 @@ def shared_cache_roots() -> list[Path]: Layout matches retcomm-toolchains install.sh: …/retcomm/toolchains/cmake-clang-v1/<tag>/ plus latest → current - RetComM (`retcomm`) is preferred; legacy `psxrecomp` remains a read/migrate + Retro (`retcomm`) is preferred; legacy `psxrecomp` remains a read/migrate fallback. Honors RETCOMM_TOOLCHAIN_CACHE / RETCOMM_DATA_HOME. """ roots: list[Path] = [] @@ -281,7 +281,7 @@ def shared_cache_roots() -> list[Path]: def preferred_install_root() -> Path: - """Where newly downloaded / offline-unpacked packs land (RetComM shared).""" + """Where newly downloaded / offline-unpacked packs land (Retro shared).""" cache = (os.environ.get("RETCOMM_TOOLCHAIN_CACHE") or "").strip() if cache: r = Path(cache).expanduser() @@ -747,7 +747,7 @@ def _register_user_path_unix(cache_root: Path, latest: Path, log=None) -> None: ) block = ( f"{_MARKER_BEGIN}\n" - f"# Managed by psxrecomp / RetComM — remove via uninstall.sh\n" + f"# Managed by psxrecomp / Retro — remove via uninstall.sh\n" f'if [ -f "{hook}" ]; then\n' f" # shellcheck disable=SC1091\n" f' . "{hook}"\n' @@ -775,7 +775,7 @@ def _register_user_path_unix(cache_root: Path, latest: Path, log=None) -> None: def register_toolchain_user_env(pack_root: Path, log=None) -> Path: """Refresh ``latest`` + idempotently add ``latest/bin`` to the user login PATH. - Mirrors retcomm-toolchains zip ``install.sh`` / ``install.ps1`` so RetComM + Mirrors retcomm-toolchains zip ``install.sh`` / ``install.ps1`` so Retro setup hosts and the standalone wizard leave cmake/clang on PATH for shell development after placing the pack in the shared cache. """ @@ -953,7 +953,7 @@ def ensure_toolchain( Resolution order: env override → project stamp/toolchain/ → shared retcomm cache (legacy psxrecomp migrated) → optional --from-zip → download. - Installs always land under the shared RetComM cache; the project only gets + Installs always land under the shared Retro cache; the project only gets a stamp file pointing at that pack. """ need = min_version or default_min_version() From fddc8b8516c14334d9c74816a361963bcc7650e4 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen <alex@technicallycomputers.ca> Date: Sat, 12 Sep 2026 19:29:57 -0400 Subject: [PATCH 8/9] netplay: report chat lines for moderation, and pin recomp-net for it PsxLobbyChatMsg gains the server's `mid` for a line, taken off `chat` and `server_chat`, and psx_lobby_report_chat hands a list of them to rnet_chat_report_build. The frame carries ids and never the text, so a report cannot fabricate what somebody said. Same eight lines as the SNES copy; what a report contains lives in recomp-net. lib/recomp-net -> 859ba28 (support chat reporting), which is also where rnet_account_set_secret_path comes from. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPXfGGhBBBxQPcEzaMsauF --- lib/recomp-net | 2 +- runtime/include/psx_lobby_client.h | 23 ++++++++++++ runtime/src/psx_lobby_client.c | 60 ++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index b4c58fcf..859ba28b 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b4c58fcfcdd3faadb3ba74da9a138ec1955c4540 +Subproject commit 859ba28b93f7374f2ac1eb70d2171594c5cd4f4b diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 9242daa1..b70521a3 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -128,6 +128,15 @@ typedef struct PsxLobbyChatMsg { char player_id[PSX_LOBBY_ID_LEN]; char from[PSX_LOBBY_NAME_LEN]; char text[PSX_LOBBY_CHAT_TEXT_LEN]; + /* The SERVER's id for this line, and what psx_lobby_report_chat names. + * Empty for a system line, a locally generated one, and anything from a + * server too old to assign one -- none of which can be reported, because + * there is no referent both sides agree on. + * + * A report carries this and NOT the text: the server writes down what it + * relayed under this id. Sending the words would let a client fabricate a + * message and have somebody sanctioned for it. */ + char mid[40]; int is_local; int is_system; uint32_t seq; @@ -400,6 +409,20 @@ void psx_lobby_seat_swap_clear(void); /* Lobby chat. send: 0 when queued (the line appears via the server echo). * count/get read the ring, oldest first; cleared on create/join/leave. */ +/* Report chat lines for moderation. Same contract on every console -- the + * frame is built by recomp-net (recomp_net/chat_report.h), which is also where + * the RNET_REPORT_* categories live; this only says where we are and sends it. + * + * `mids` are PsxLobbyChatMsg.mid values, several at once because harassment is + * usually a burst rather than a line. Entries with an empty id are skipped. + * The text is never sent: see PsxLobbyChatMsg.mid. + * + * 0 when handed to the server, which is not the same as accepted -- it refuses + * an expired line, a guest's line, your own, and anything over the rate + * limit. */ +int psx_lobby_report_chat(const char *const *mids, int mid_count, + const char *reason, const char *note); + int psx_lobby_send_chat(const char *text); /* Server chat: per-game, outside any room (op server_chat). Its own ring, diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 73ff7350..e0615747 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -1,4 +1,5 @@ #include "psx_lobby_client.h" +#include "recomp_net/chat_report.h" #include <ctype.h> #include <errno.h> @@ -122,6 +123,9 @@ const PsxLobbyMemcardOffer *psx_lobby_memcard_offer(void) return &z; } int psx_lobby_send_chat(const char *text) { (void)text; return -1; } +int psx_lobby_report_chat(const char *const *m, int c, const char *r, + const char *n) +{ (void)m; (void)c; (void)r; (void)n; return -1; } int psx_lobby_seat_move_self(int to_slot) { (void)to_slot; return -1; } int psx_lobby_seat_swap_request(int target_slot) { (void)target_slot; return -1; } int psx_lobby_seat_swap_incoming(char *who, size_t who_cap, int *from_slot) @@ -1803,7 +1807,7 @@ static void drain_ws_pending(void) } static void chat_push(const char *player_id, const char *from, const char *text, - int is_system) + const char *mid, int is_system) { PsxLobbyChatMsg *m; int idx; @@ -1820,6 +1824,8 @@ static void chat_push(const char *player_id, const char *from, const char *text, snprintf(m->player_id, sizeof(m->player_id), "%s", player_id ? player_id : ""); snprintf(m->from, sizeof(m->from), "%s", from ? from : ""); snprintf(m->text, sizeof(m->text), "%s", text); + snprintf(m->mid, sizeof(m->mid), "%s", + (mid && !is_system) ? mid : ""); /* Masked on arrival, whatever relayed it: the server already did this, * an older server did not, and the rule is that nothing unmasked is * ever shown. */ @@ -1830,7 +1836,8 @@ static void chat_push(const char *player_id, const char *from, const char *text, m->seq = ++g_lc.chat_seq; } -static void schat_push(const char *player_id, const char *from, const char *text) +static void schat_push(const char *player_id, const char *from, const char *text, + const char *mid) { PsxLobbyChatMsg *m; int idx; @@ -1847,6 +1854,7 @@ static void schat_push(const char *player_id, const char *from, const char *text snprintf(m->player_id, sizeof(m->player_id), "%s", player_id ? player_id : ""); snprintf(m->from, sizeof(m->from), "%s", from ? from : ""); snprintf(m->text, sizeof(m->text), "%s", text); + snprintf(m->mid, sizeof(m->mid), "%s", mid ? mid : ""); (void)rnet_chat_filter_apply(m->text, sizeof(m->text)); m->is_local = (g_lc.player_id[0] && player_id && strcmp(player_id, g_lc.player_id) == 0) ? 1 : 0; @@ -2299,26 +2307,32 @@ static void handle_server_json(const char *json) char text[PSX_LOBBY_CHAT_TEXT_LEN]; char from_id[PSX_LOBBY_ID_LEN]; char from[PSX_LOBBY_NAME_LEN]; + char mid[40]; text[0] = '\0'; from_id[0] = '\0'; from[0] = '\0'; + mid[0] = '\0'; json_get_str(json, "text", text, sizeof(text)); json_get_str(json, "from_player_id", from_id, sizeof(from_id)); json_get_str(json, "from", from, sizeof(from)); - schat_push(from_id, from, text); + json_get_str(json, "mid", mid, sizeof(mid)); + schat_push(from_id, from, text, mid); return; } if (strcmp(op, "chat") == 0) { char text[PSX_LOBBY_CHAT_TEXT_LEN]; char from_id[PSX_LOBBY_ID_LEN]; char from[PSX_LOBBY_NAME_LEN]; + char mid[40]; text[0] = '\0'; from_id[0] = '\0'; from[0] = '\0'; + mid[0] = '\0'; json_get_str(json, "text", text, sizeof(text)); json_get_str(json, "from_player_id", from_id, sizeof(from_id)); json_get_str(json, "from", from, sizeof(from)); - chat_push(from_id, from, text, json_get_bool(json, "system", 0)); + json_get_str(json, "mid", mid, sizeof(mid)); + chat_push(from_id, from, text, mid, json_get_bool(json, "system", 0)); return; } if (strcmp(op, "signal") == 0) { @@ -3707,6 +3721,44 @@ void psx_lobby_seat_swap_clear(void) if (g_lc.swap_out != 1) g_lc.swap_out = 0; } +int psx_lobby_report_chat(const char *const *mids, int mid_count, + const char *reason, const char *note) +{ + /* Thin on purpose, and identical in shape to the SNES copy. What a report + * CONTAINS lives in recomp-net (recomp_net/chat_report.h) so there is one + * implementation rather than one per console; this says where we are and + * hands the frame to the socket. */ + RNetChatReportMeta meta; + char msg[2048]; + size_t n; + + if (!psx_lobby_connected()) + return -1; + + memset(&meta, 0, sizeof(meta)); + meta.game = g_lc.filter_game_name; + meta.game_version = psx_lobby_game_version(); + /* Metadata only. Nothing downstream may name a file or a directory after + * it -- one moderation queue spans every title, and splitting the evidence + * by console would fragment it along a line that has nothing to do with + * moderation. */ + meta.platform = "psx"; + /* The host this client connected to. PSX keeps the parsed host rather + * than the whole URL, which is the part that identifies a deployment and + * the part a moderator needs. */ + meta.server = g_lc.host; + meta.lobby = g_lc.join.lobby_id[0] ? g_lc.join.lobby_id : ""; + meta.scope = g_lc.in_lobby ? "lobby" : "server"; + + n = rnet_chat_report_build(msg, sizeof(msg), mids, mid_count, + reason, note, &meta); + if (n == 0) + return -1; + queue_send(msg); + flush_pending(); + return 0; +} + int psx_lobby_send_chat(const char *text) { char esc[PSX_LOBBY_CHAT_TEXT_LEN * 2 + 8]; From b9ca60077882b35bfae6e11cbfebe6d01fd8e19d Mon Sep 17 00:00:00 2001 From: Alex Vanderveen <alex@technicallycomputers.ca> Date: Sat, 12 Sep 2026 19:39:09 -0400 Subject: [PATCH 9/9] netplay: block list, account keys, list scope and automatch for the launcher Brings the PSX runtime level with what recomp-ui's netplay pages now ask a host for, and with the SNES runner that already answers them (snes_lobby_client.c / snes_host_lobby.c): - Chat lines, seat rows and players-online rows carry the server's opaque account key, so the launcher's ignore/block list survives a rename. - psx_lobby_set_blocks pushes that list to the server as `set_blocks`, since refusing to pair or seat the two together is the server's job; recomp-ui re-sends it on every reconnect. - list_scope_set: a LAN player no longer sees online rooms they have no connection for, and an online player no longer sees their own LAN rows. - Automatch: rulesets discovery, the relay latency probe (14-byte LE frame, type 200 -> 201, nonce-matched), queue with the disc fingerprint, the accept gate with a locally counted deadline, requeue/cancel/cooldown, and claiming automatch's `error` codes only while a ticket is in flight. Leaving an automatch room after the match: it is the server's room, has no host to rematch with, and holding the seat blocks the next ticket. - Account: init the auth client once per URL rather than every pump (the login worker reads the host), anchor the device key to the executable directory, and publish the signed-in handle as the lobby display name. mods_enabled is asserted false on PSX: every netplay launch clears the mod plan or refuses to start, and the caps a match runs are the ruleset's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPXfGGhBBBxQPcEzaMsauF --- runtime/include/psx_lobby_client.h | 135 ++++++ runtime/src/main.cpp | 217 ++++++++- runtime/src/psx_lobby_client.c | 755 ++++++++++++++++++++++++++++- 3 files changed, 1097 insertions(+), 10 deletions(-) diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index b70521a3..60e62746 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -71,6 +71,11 @@ typedef struct PsxLobbyOnlinePlayer { /* The title that player is browsing for; rows of other titles are * dropped at parse time when this client has a game identity. */ char game_name[PSX_LOBBY_NAME_LEN]; + /* Opaque, stable id for the ACCOUNT behind this connection; "" for a + * guest. The server's own row key, published so a client-side block + * list can survive the other player reconnecting or renaming. A key, + * never a label: nothing renders it. */ + char account[PSX_LOBBY_ID_LEN]; } PsxLobbyOnlinePlayer; #define PSX_LOBBY_MAX_ONLINE 64 @@ -95,6 +100,9 @@ typedef struct PsxLobbyMember { int memcard_share; /* peer opted in to bring it to the match */ /* Country (alpha-2) from the server's GeoIP; "" unknown. */ char country[4]; + /* Account key behind this seat; "" for a guest. See + * PsxLobbyOnlinePlayer.account. */ + char account[PSX_LOBBY_ID_LEN]; } PsxLobbyMember; /* @@ -137,6 +145,9 @@ typedef struct PsxLobbyChatMsg { * relayed under this id. Sending the words would let a client fabricate a * message and have somebody sanctioned for it. */ char mid[40]; + /* Account key of the sender; "" for a guest or a system line. What an + * ignore/block keys on, so it outlives a rename. */ + char account[PSX_LOBBY_ID_LEN]; int is_local; int is_system; uint32_t seq; @@ -461,6 +472,130 @@ void psx_lobby_clear_launch_pending(void); * Launch suspends them so they cannot steal match ICE signaling. */ void psx_lobby_resume_waiting_room_rtt(void); +/* + * Tell the server which accounts this player has blocked. + * + * ';'-separated opaque account ids, replacing the whole set -- the client's + * own file (recomp-ui moderation.ini) is the authority, so an unblock needs + * no separate op. NULL/"" clears. + * + * Sent to the SERVER rather than applied only here, because two of the three + * things a block has to do cannot be done from this side: the matchmaker must + * not pair the two, and the blocked player must not see or be able to join + * the blocker's room. A client can hide what it was sent; it cannot know it + * was blocked by somebody else, and that is the direction that matters. + * + * The server holds the list for the life of a connection and never persists + * it; recomp-ui re-sends it on every reconnect. + */ +int psx_lobby_set_blocks(const char *accounts); + +/* -- Automatch -------------------------------------------------------------- + * + * Server-run pairing: the player asks for a match and the SERVER creates the + * room, is its host, and owns its match_caps from a named ruleset. Protocol: + * recomp-net-server docs/AUTOMATCH.md. Everything downstream of the pairing + * is the ordinary `joined` / `lobby_update` / `launch` path already in this + * file, so this adds a queue and an accept gate and nothing else. + * + * Requires a signed-in account: the accept gate charges a dodge cooldown, and + * a cost that reconnecting erases is not a cost. + * + * Same shape as the SNES client (snes_lobby_client.h), on purpose: the host + * adapter in main.cpp is then a one-line translation per entry point. + */ +#define PSX_LOBBY_MAX_RULESETS 8 +#define PSX_LOBBY_RULESET_ID_LEN 48 +#define PSX_LOBBY_RULESET_LABEL_LEN 64 +#define PSX_LOBBY_CAPS_SUMMARY_LEN 128 + +/* One queue type this server offers for this title. */ +typedef struct PsxLobbyRuleset { + char id[PSX_LOBBY_RULESET_ID_LEN]; + char label[PSX_LOBBY_RULESET_LABEL_LEN]; + /* Server-derived one-liner ("Delay 2 - Rollback on"). Derived from the + * caps rather than authored, so it cannot drift from what the match + * runs. The caps themselves arrive with `joined` like any host's. */ + char caps_summary[PSX_LOBBY_CAPS_SUMMARY_LEN]; + /* Empty = any release may queue (still only pooling with its own). */ + char game_version[PSX_LOBBY_VERSION_LEN]; + int max_slots; +} PsxLobbyRuleset; + +/* Matches RECOMP_LAUNCHER_AUTOMATCH_* so the host layer can hand these + * straight to the launcher without a translation table that could drift. */ +enum { + PSX_LOBBY_AUTOMATCH_IDLE = 0, + PSX_LOBBY_AUTOMATCH_QUEUED = 1, + PSX_LOBBY_AUTOMATCH_FOUND = 2, + PSX_LOBBY_AUTOMATCH_ACCEPTED = 3, + PSX_LOBBY_AUTOMATCH_FAILED = 4 +}; + +/* The offer on the table while state is FOUND. */ +typedef struct PsxLobbyAutomatchFound { + char opponent[PSX_LOBBY_NAME_LEN]; + /* Discord @username: display names are not unique, so this is the + * disambiguator. "" when the server did not say. */ + char opponent_username[PSX_LOBBY_NAME_LEN]; + char opponent_country[4]; + char ruleset_id[PSX_LOBBY_RULESET_ID_LEN]; + char ruleset_label[PSX_LOBBY_RULESET_LABEL_LEN]; + int est_rtt_ms; /* the pair's estimate: rtt_a + rtt_b; <0 unknown */ + int accept_secs; /* seconds left to answer, recomputed per read */ +} PsxLobbyAutomatchFound; + +/* Ask the server what queues it offers for this title. Answered into + * psx_lobby_automatch_ruleset_count/get; 0 of them means automatch is off + * here. Refuses to re-send while one is outstanding. */ +int psx_lobby_automatch_request_rulesets(void); +/* 1 once the server has answered with at least one ruleset. Not having + * asked yet is "no": the button must not be offered on an assumption. */ +int psx_lobby_automatch_available(void); +int psx_lobby_automatch_ruleset_count(void); +int psx_lobby_automatch_ruleset_get(int index, PsxLobbyRuleset *out); + +/* + * Join the queue. `ruleset_id` NULL/"" takes the first one. + * + * `mods_enabled` is the caller's assertion that a SIM-AFFECTING mod feature is + * on locally, beyond whatever the ruleset itself imposes. The server refuses a + * true with mods_not_pooled and cannot check it -- the assertion exists so a + * modified client makes a deliberate false statement rather than exploiting + * an omission (AUTOMATCH.md 5). The host layer decides it; this file only + * carries it. `mod_exempt` is ';'-separated evidence for cosmetic exemptions + * (NULL or "" = none), sent as a JSON array for the server to check. + * + * 0 = sent. <0 = refused locally (not connected, no fingerprint, already + * queued). A server refusal arrives asynchronously as state FAILED with a + * reason in psx_lobby_automatch_error(). + */ +int psx_lobby_automatch_queue(const char *ruleset_id, int mods_enabled, + const char *mod_exempt); +int psx_lobby_automatch_cancel(void); +int psx_lobby_automatch_state(void); +int psx_lobby_automatch_queued_secs(void); +int psx_lobby_automatch_pool(void); +int psx_lobby_automatch_found_get(PsxLobbyAutomatchFound *out); +/* accept != 0 accepts; 0 declines and takes the dodge strike. */ +int psx_lobby_automatch_accept(int accept); +/* + * Non-zero while the room this client is seated in was created by AUTOMATCH. + * + * A human-hosted room is somebody's room: it survives the match, and staying + * seated is how a rematch happens. An automatch room is the server's, it is + * created at both-accept and is not joinable by anyone, and there is no host + * to rematch with -- so staying in it leaves the player parked in a room that + * can never fill, and the server refuses their next ticket with + * `already_in_lobby`. Cleared by leaving, and by anything else that ends the + * seating. + */ +int psx_lobby_automatch_room(void); +/* Refuse a queue locally, with the reason the player sees. For when the HOST + * already knows the server would bounce the ticket. */ +void psx_lobby_automatch_refuse_local(const char *why); +const char *psx_lobby_automatch_error(void); + #ifdef __cplusplus } #endif diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b62e2b70..4ed1904b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10037,6 +10037,139 @@ namespace { int ae_np_account_sign_out(void*) { return rnet_account_sign_out(); } int ae_np_account_set_handle(void*, const char* h) { return rnet_account_set_handle(h); } + /* Point the account client at the lobby host -- once per URL, not once + * per pump: the login worker thread reads the host while a sign-in is in + * flight, and re-initialising it 60 times a second under that read is a + * data race for no gain. The secret is anchored to the EXECUTABLE + * directory before the first init: its default is the bare relative name + * "netplay_secret", resolved against the working directory, so the same + * install signed itself out depending on where it was launched from. + * rnet_auth migrates an old CWD-relative file into this path on first + * load, so nobody is signed out by the move. Same shape as the SNES + * host (snes_host_lobby.c cb_pump). */ + void ae_np_account_sync(void) { + static std::string s_auth_url; + const std::string& url = g_lnch_lobby_url; + if (url.empty() || url == s_auth_url) return; + if (s_auth_url.empty()) { + const std::string secret = + (exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "") / + "netplay_secret").string(); + rnet_account_set_secret_path(secret.c_str()); + } + s_auth_url = url; + rnet_account_init(url.c_str()); + } + + /* ---- list scope -------------------------------------------------------- + * The launcher forks LAN / Direct IP from online before the browser, and + * only it knows which fork the player took. Without the scope a player + * who chose LAN was shown online rooms they had no connection for, and + * one who chose online was shown LAN rooms from their own machine. The + * values are RECOMP_LAUNCHER_LIST_SCOPE_*; 0 (any) is the historical + * merge, and what a recomp-ui without the callback leaves us in. */ + int g_lnch_list_scope = 0; + int ae_np_list_scope_set(void*, int scope) { + g_lnch_list_scope = scope; + return 0; + } + bool ae_np_list_want_online(void) { return g_lnch_list_scope != 1; } + bool ae_np_list_want_lan(void) { return g_lnch_list_scope != 2; } + + /* ---- moderation ------------------------------------------------------ + * Both go to the lobby server; a LAN room has none, and the launcher + * only offers them online. What a report contains is recomp-net's + * (chat_report.h); the block list is the launcher's own file, pushed + * here so the server can refuse to pair or seat the two together. */ + int ae_np_chat_report(void*, const char* const* mids, int mid_count, + const char* reason, const char* note) { + if (g_lnch_hosting_lan || g_lnch_joined_lan) return -1; + return psx_lobby_report_chat(mids, mid_count, reason, note); + } + int ae_np_set_blocks(void*, const char* accounts) { + if (g_lnch_hosting_lan || g_lnch_joined_lan) return -1; + return psx_lobby_set_blocks(accounts); + } + + /* ---- automatch ------------------------------------------------------- + * Thin: the lobby client owns the protocol and the state machine, and + * this only translates its vocabulary into the launcher's. The one piece + * of POLICY here is mods_enabled -- see ae_np_automatch_queue. */ + bool ae_np_online_mode(void) { + return !g_lnch_hosting_lan && !g_lnch_joined_lan && psx_lobby_connected(); + } + int ae_np_automatch_available(void*) { + if (!ae_np_online_mode()) return 0; + /* Ask once the answer could exist. The launcher polls this every + * frame while the netplay page is up, which is exactly when a reply + * is useful, and the client refuses to re-send while one is + * outstanding. */ + if (!psx_lobby_automatch_available()) + (void)psx_lobby_automatch_request_rulesets(); + return psx_lobby_automatch_available(); + } + int ae_np_automatch_ruleset_count(void*) { + return psx_lobby_automatch_ruleset_count(); + } +#if defined(RECOMP_LAUNCHER_HAS_AUTOMATCH) + int ae_np_automatch_ruleset_get(void*, int index, RecompLauncherCNetplayRuleset* out) { + PsxLobbyRuleset r{}; + if (!out || !psx_lobby_automatch_ruleset_get(index, &r)) return 0; + std::memset(out, 0, sizeof(*out)); + std::snprintf(out->id, sizeof(out->id), "%s", r.id); + std::snprintf(out->label, sizeof(out->label), "%s", r.label); + std::snprintf(out->caps_summary, sizeof(out->caps_summary), "%s", r.caps_summary); + std::snprintf(out->game_version, sizeof(out->game_version), "%s", r.game_version); + out->max_slots = r.max_slots > 0 ? r.max_slots : 2; + return 1; + } + int ae_np_automatch_found_get(void*, RecompLauncherCNetplayFound* out) { + PsxLobbyAutomatchFound f{}; + if (!out || !psx_lobby_automatch_found_get(&f)) return 0; + std::memset(out, 0, sizeof(*out)); + std::snprintf(out->handle, sizeof(out->handle), "%s", f.opponent); + std::snprintf(out->username, sizeof(out->username), "%s", f.opponent_username); + std::snprintf(out->country, sizeof(out->country), "%s", f.opponent_country); + std::snprintf(out->ruleset_label, sizeof(out->ruleset_label), "%s", f.ruleset_label); + out->est_rtt_ms = f.est_rtt_ms; + out->accept_secs_left = f.accept_secs; + return 1; + } +#endif + int ae_np_automatch_queue(void*, const char* ruleset_id) { + if (!ae_np_online_mode()) return -1; + /* mods_enabled asserts that a SIM-AFFECTING mod feature is on locally + * beyond what the ruleset imposes. On PSX that is never true for a + * netplay session: every netplay launch, rematch included, goes + * through mod_runtime_clear_for_netplay and refuses to start if the + * plan cannot be cleared, and the caps a match runs (aspect, turbo + * loads, BIOS, FMV skip) are the server's ruleset, settled through + * match_caps like any host's. There is no cosmetic-exemption + * mechanism on this runtime either, so the evidence list is empty. */ + return psx_lobby_automatch_queue(ruleset_id, 0, "") == 0 ? 0 : -1; + } + int ae_np_automatch_cancel(void*) { return psx_lobby_automatch_cancel(); } + int ae_np_automatch_state(void*) { return psx_lobby_automatch_state(); } + int ae_np_automatch_queued_secs(void*) { return psx_lobby_automatch_queued_secs(); } + int ae_np_automatch_pool(void*) { return psx_lobby_automatch_pool(); } + int ae_np_automatch_accept(void*, int accept) { return psx_lobby_automatch_accept(accept); } + const char* ae_np_automatch_error(void*) { return psx_lobby_automatch_error(); } + + /* After a match: an automatch room is the server's, not a host's. It is + * created at both-accept, nobody can join it, and there is no host to + * rematch with -- staying seated parks the player in a room that can + * never fill, and the server refuses their next ticket with + * already_in_lobby. So leave it, and tell the caller not to reopen the + * launcher on the room. 1 when a room was left. */ + int ae_np_leave_automatch_room_after_match(void) { + if (g_lnch_hosting_lan || g_lnch_joined_lan) return 0; + if (!psx_lobby_automatch_room()) return 0; + std::fprintf(stderr, + "psxrecomp: leaving the automatch room (no host to rematch with)\n"); + (void)psx_lobby_leave(); + return 1; + } + int ae_np_chat_count(void*) { ae_np_chat_track_room(); if (g_lnch_hosting_lan || g_lnch_joined_lan) return g_lnch_lan_chat_count; @@ -10060,6 +10193,14 @@ namespace { if (!psx_lobby_chat_get(index, &msg)) return 0; std::snprintf(out->from, sizeof(out->from), "%s", msg.from); std::snprintf(out->text, sizeof(out->text), "%s", msg.text); +#if defined(RECOMP_LAUNCHER_HAS_CHAT_REPORT) + /* The server's id for the line -- what a report names. Empty for a + * system line or an older server, and then it cannot be reported. */ + std::snprintf(out->mid, sizeof(out->mid), "%s", msg.mid); +#endif +#if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) + std::snprintf(out->account, sizeof(out->account), "%s", msg.account); +#endif out->is_local = msg.is_local; out->is_system = msg.is_system; out->seq = msg.seq; @@ -10085,6 +10226,14 @@ namespace { if (!psx_lobby_server_chat_get(index, &msg)) return 0; std::snprintf(out->from, sizeof(out->from), "%s", msg.from); std::snprintf(out->text, sizeof(out->text), "%s", msg.text); +#if defined(RECOMP_LAUNCHER_HAS_CHAT_REPORT) + /* The server's id for the line -- what a report names. Empty for a + * system line or an older server, and then it cannot be reported. */ + std::snprintf(out->mid, sizeof(out->mid), "%s", msg.mid); +#endif +#if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) + std::snprintf(out->account, sizeof(out->account), "%s", msg.account); +#endif out->is_local = msg.is_local; out->is_system = msg.is_system; out->seq = msg.seq; @@ -10244,7 +10393,7 @@ namespace { ae_np_save_identity(nullptr, g_lnch_lobby_url.c_str()); /* The auth endpoints live on the same host and port as the lobby * socket, so the sign-in follows whatever server the player points at. */ - rnet_account_init(g_lnch_lobby_url.c_str()); + ae_np_account_sync(); } int ae_np_connect(void*) { @@ -10822,11 +10971,27 @@ namespace { } void ae_np_pump(void*) { - psx_lobby_pump(); /* Redeems a stored device key on the first pump, so a machine that has * signed in once comes up signed in with no player action. */ - rnet_account_init(g_lnch_lobby_url.c_str()); + ae_np_account_sync(); rnet_account_pump(); + /* Publish the account name to the lobby. The lobby's display name is + * what seats and the players-online list show, and nothing else + * pushed the handle into it: signing in -- including the automatic + * sign-in from a stored secret -- only updated the ACCOUNT, so a + * signed-in player created a room and appeared under the LAN name. + * Done here rather than at a sign-in edge because there is no single + * such edge: interactive login, stored-secret redemption and a + * server-side handle change all land asynchronously in the pump. + * Comparing against the live name makes this idempotent -- + * set_display_name only re-sends hello when the value changed. */ + if (rnet_account_state() == RNET_ACCOUNT_SIGNED_IN) { + const char* handle = rnet_account_handle(); + const char* shown = psx_lobby_display_name(); + if (handle && handle[0] && (!shown || std::strcmp(shown, handle) != 0)) + psx_lobby_set_display_name(handle); + } + psx_lobby_pump(); ae_np_lan_browse_pump(); ae_np_lan_udp_pump(); /* Lobby UI has no Ready toggle; production WS still requires every @@ -10870,14 +11035,16 @@ namespace { } int ae_np_list_count(void*) { - return psx_lobby_list_count() + ae_np_lan_list_extra_count(); + return (ae_np_list_want_online() ? psx_lobby_list_count() : 0) + + (ae_np_list_want_lan() ? ae_np_lan_list_extra_count() : 0); } int ae_np_list_get(void*, int index, RecompLauncherCNetplayLobby* out) { if (!out) return 0; - const int remote_count = psx_lobby_list_count(); + const int remote_count = ae_np_list_want_online() ? psx_lobby_list_count() : 0; if (index >= remote_count) { const int lan_i = index - remote_count; + if (!ae_np_list_want_lan()) return 0; ae_np_lan_prune_discovered(); if (g_lnch_lan_discovered_n > 0) return ae_np_lan_fill_lobby_from_discovered(lan_i, out); @@ -10914,6 +11081,9 @@ namespace { std::memset(out, 0, sizeof(*out)); std::snprintf(out->display_name, sizeof(out->display_name), "%s", p.display_name); std::snprintf(out->country, sizeof(out->country), "%s", p.country); +#if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) + std::snprintf(out->account, sizeof(out->account), "%s", p.account); +#endif std::snprintf(out->lobby_name, sizeof(out->lobby_name), "%s", p.lobby_name); out->in_lobby = p.lobby_id[0] != '\0'; out->hosting = p.hosting; @@ -11472,6 +11642,9 @@ namespace { out->memcard_has_card = mem.memcard_has_card; out->memcard_share = mem.memcard_share; std::snprintf(out->country, sizeof(out->country), "%s", mem.country); + #if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) + std::snprintf(out->account, sizeof(out->account), "%s", mem.account); + #endif return 1; } if (ae_np_use_lan_members()) { @@ -11533,6 +11706,9 @@ namespace { out->memcard_has_card = mem.memcard_has_card; out->memcard_share = mem.memcard_share; std::snprintf(out->country, sizeof(out->country), "%s", mem.country); + #if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) + std::snprintf(out->account, sizeof(out->account), "%s", mem.account); + #endif return 1; } @@ -12094,6 +12270,30 @@ namespace { g_lnch_netplay_callbacks.account_error = ae_np_account_error; g_lnch_netplay_callbacks.account_sign_out = ae_np_account_sign_out; g_lnch_netplay_callbacks.account_set_handle = ae_np_account_set_handle; +#endif +#if defined(RECOMP_LAUNCHER_HAS_LIST_SCOPE) + g_lnch_netplay_callbacks.list_scope_set = ae_np_list_scope_set; +#endif +#if defined(RECOMP_LAUNCHER_HAS_CHAT_REPORT) + g_lnch_netplay_callbacks.chat_report = ae_np_chat_report; +#endif +#if defined(RECOMP_LAUNCHER_HAS_SET_BLOCKS) + g_lnch_netplay_callbacks.set_blocks = ae_np_set_blocks; +#endif +#if defined(RECOMP_LAUNCHER_HAS_AUTOMATCH) + /* Server-run pairing. Same guard discipline as the account block: + * the runner and the UI land in either order. */ + g_lnch_netplay_callbacks.automatch_available = ae_np_automatch_available; + g_lnch_netplay_callbacks.automatch_ruleset_count = ae_np_automatch_ruleset_count; + g_lnch_netplay_callbacks.automatch_ruleset_get = ae_np_automatch_ruleset_get; + g_lnch_netplay_callbacks.automatch_queue = ae_np_automatch_queue; + g_lnch_netplay_callbacks.automatch_cancel = ae_np_automatch_cancel; + g_lnch_netplay_callbacks.automatch_state = ae_np_automatch_state; + g_lnch_netplay_callbacks.automatch_queued_secs = ae_np_automatch_queued_secs; + g_lnch_netplay_callbacks.automatch_pool = ae_np_automatch_pool; + g_lnch_netplay_callbacks.automatch_found_get = ae_np_automatch_found_get; + g_lnch_netplay_callbacks.automatch_accept = ae_np_automatch_accept; + g_lnch_netplay_callbacks.automatch_error = ae_np_automatch_error; #endif g_lnch_netplay_callbacks.seat_move_self = ae_np_seat_move_self; g_lnch_netplay_callbacks.seat_swap_request = ae_np_seat_swap_request; @@ -15645,6 +15845,11 @@ soft_return_lobby: std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) + " - Launcher"; std::string rui_initial_disc = disc_path_str; + /* A human-hosted room survives the match and is where a rematch + * happens; an automatch room is the server's and cannot. Leave it + * here, and reopen the launcher on the browser instead. */ + const int rui_resume_room = + ae_np_leave_automatch_room_after_match() ? 0 : 1; ae_rui_set_sidecar_paths(argv[0]); g_lnch_expected_serial = game_id; @@ -15822,7 +16027,7 @@ soft_return_lobby: ctrl_locked_mode[0], rui_lang_labels.empty() ? nullptr : rui_lang_labels.data(), (int)rui_lang_labels.size(), - /*resume_netplay_room=*/1); + /*resume_netplay_room=*/rui_resume_room); gi.discs = rui_discs.empty() ? nullptr : rui_discs.data(); gi.num_discs = (int)rui_discs.size(); #if defined(PSX_HAS_SETUP_WIZARD) && defined(PSX_HAS_CODEGEN_SETUP_HOST) diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index e0615747..4dc5d738 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -126,6 +126,22 @@ int psx_lobby_send_chat(const char *text) { (void)text; return -1; } int psx_lobby_report_chat(const char *const *m, int c, const char *r, const char *n) { (void)m; (void)c; (void)r; (void)n; return -1; } +int psx_lobby_set_blocks(const char *accounts) { (void)accounts; return -1; } +int psx_lobby_automatch_request_rulesets(void) { return -1; } +int psx_lobby_automatch_available(void) { return 0; } +int psx_lobby_automatch_ruleset_count(void) { return 0; } +int psx_lobby_automatch_ruleset_get(int i, PsxLobbyRuleset *o) { (void)i; (void)o; return 0; } +int psx_lobby_automatch_queue(const char *r, int m, const char *e) +{ (void)r; (void)m; (void)e; return -1; } +int psx_lobby_automatch_cancel(void) { return -1; } +int psx_lobby_automatch_state(void) { return PSX_LOBBY_AUTOMATCH_IDLE; } +int psx_lobby_automatch_queued_secs(void) { return 0; } +int psx_lobby_automatch_pool(void) { return 0; } +int psx_lobby_automatch_found_get(PsxLobbyAutomatchFound *o) { (void)o; return 0; } +int psx_lobby_automatch_accept(int a) { (void)a; return -1; } +int psx_lobby_automatch_room(void) { return 0; } +void psx_lobby_automatch_refuse_local(const char *w) { (void)w; } +const char *psx_lobby_automatch_error(void) { return ""; } int psx_lobby_seat_move_self(int to_slot) { (void)to_slot; return -1; } int psx_lobby_seat_swap_request(int target_slot) { (void)target_slot; return -1; } int psx_lobby_seat_swap_incoming(char *who, size_t who_cap, int *from_slot) @@ -1351,6 +1367,8 @@ static void lobby_list_parse_players(const char *json) sizeof(g_lc.online[n].lobby_name)); g_lc.online[n].hosting = json_get_bool(chunk, "hosting", 0); json_get_str(chunk, "tag", g_lc.online[n].tag, sizeof(g_lc.online[n].tag)); + json_get_str(chunk, "account", g_lc.online[n].account, + sizeof(g_lc.online[n].account)); json_get_str(chunk, "game_name", g_lc.online[n].game_name, sizeof(g_lc.online[n].game_name)); /* Players of another title are not "online" for this one. A row @@ -1695,6 +1713,8 @@ static int parse_seat_array(const char *json, const char *key, int is_spectator, } json_get_str(chunk, "country", g_lc.members[n].country, sizeof(g_lc.members[n].country)); + json_get_str(chunk, "account", g_lc.members[n].account, + sizeof(g_lc.members[n].account)); if (json_extract_object(chunk, "memcard_offer", offer, sizeof(offer))) { g_lc.members[n].memcard_offer_valid = 1; g_lc.members[n].memcard_has_card = @@ -1761,6 +1781,699 @@ static void ingest_host_player_id(const char *json) } } + +/* ── Block list ────────────────────────────────────────────────────────────── */ + +int psx_lobby_set_blocks(const char *accounts) +{ + /* Room for the server's cap (256 ids) at 40 characters each, plus the + * separators and the envelope. A list that would not fit is truncated at + * a separator rather than sent malformed -- a half-written id at the end + * would name nobody, and a malformed op would leave the server enforcing + * nothing at all. */ + static char msg[256 * 41 + 64]; + static char list[256 * 41]; + size_t n = 0; + int first = 1; + const char *p = accounts ? accounts : ""; + + if (!g_lc.connected) return -1; + list[0] = '\0'; + while (*p) { + const char *sep = strchr(p, ';'); + size_t len = sep ? (size_t)(sep - p) : strlen(p); + if (len && len < 40 && n + len + 8 < sizeof(list)) { + if (!first) { list[n++] = ','; } + list[n++] = '"'; + memcpy(list + n, p, len); + n += len; + list[n++] = '"'; + list[n] = '\0'; + first = 0; + } + if (!sep) break; + p = sep + 1; + } + snprintf(msg, sizeof(msg), "{\"op\":\"set_blocks\",\"accounts\":[%s]}", list); + queue_send(msg); + flush_pending(); + return 0; +} + +/* ── Automatch ───────────────────────────────────────────────────────────── + * + * State machine and wire for recomp-net-server docs/AUTOMATCH.md. The room a + * pairing produces arrives as an ordinary `joined` and is handled by the + * existing code; this owns the ticket, the accept gate, and the latency + * probe the server uses to pick an input delay. + */ +#define AUTOMATCH_PROBE_LEN 14 + +static int set_nonblock(int fd); /* defined with the socket code below */ + +typedef struct { + int have_rulesets; /* the server has answered once this connection */ + int rulesets_in_flight; + int ruleset_count; + PsxLobbyRuleset rulesets[PSX_LOBBY_MAX_RULESETS]; + + int state; /* PSX_LOBBY_AUTOMATCH_* */ + char ticket_id[PSX_LOBBY_ID_LEN]; + char match_id[PSX_LOBBY_ID_LEN]; + int queued_secs; + int pool; + char error[160]; + + PsxLobbyAutomatchFound found; + /* When the offer lapses, as a monotonic timestamp rather than the count + * the server sent. The server states the deadline once; a client that + * stored the number and showed it unchanged would display "15s" for the + * whole fifteen seconds, which reads as a frozen dialog. */ + uint64_t found_deadline_ms; + + /* Where to send the latency probe, published on rulesets_ok and again on + * automatch_queued. Kept from whichever arrived last. */ + char probe_host[128]; + int probe_port; + unsigned probe_magic; + int probe_type; + /* The measurement, and the nonce that identifies our outstanding probe. */ + int rtt_ms; /* <0 = not measured yet */ + uint32_t probe_nonce; + uint64_t probe_sent_ms; /* 0 = none outstanding */ + int probe_socket; /* -1 = not open */ + int rtt_reported; /* the server has our number */ + /* A queue op is out and its answer -- automatch_queued, or an error -- + * has not arrived. Without it a refusal of the FIRST queue attempt + * (need_account is the common one) would arrive while state is still + * IDLE and be filed as somebody else's error. */ + int queue_in_flight; + /* This seat came from a pairing, not from a room somebody hosts. */ + int in_automatch_room; +} LobbyAutomatch; + +static LobbyAutomatch g_am = { .rtt_ms = -1, .probe_socket = -1 }; + +static void automatch_reset_queue_state(void) +{ + g_am.state = PSX_LOBBY_AUTOMATCH_IDLE; + g_am.queue_in_flight = 0; + g_am.ticket_id[0] = '\0'; + g_am.match_id[0] = '\0'; + g_am.queued_secs = 0; + g_am.pool = 0; + g_am.rtt_reported = 0; + g_am.found_deadline_ms = 0; + memset(&g_am.found, 0, sizeof(g_am.found)); +} + +static void automatch_fail(const char *why) +{ + g_am.state = PSX_LOBBY_AUTOMATCH_FAILED; + g_am.queue_in_flight = 0; + snprintf(g_am.error, sizeof(g_am.error), "%s", why ? why : "automatch failed"); + fprintf(stderr, "psx_lobby: automatch failed: %s\n", g_am.error); +} + +static void automatch_probe_close(void) +{ + if (g_am.probe_socket >= 0) { + close(g_am.probe_socket); + g_am.probe_socket = -1; + } + g_am.probe_sent_ms = 0; +} + +/* A new connection has no ticket and has not been told what queues exist: + * the server holds both per socket. Called from `welcome` and on disconnect + * so the availability question is re-asked, not answered from a stale yes. */ +static void automatch_on_connection_reset(void) +{ + automatch_reset_queue_state(); + automatch_probe_close(); + g_am.have_rulesets = 0; + g_am.rulesets_in_flight = 0; + g_am.ruleset_count = 0; + g_am.rtt_ms = -1; + g_am.in_automatch_room = 0; +} + +/* + * The 14-byte probe: magic, type, then a nonce. + * + * LITTLE-ENDIAN, because that is what the relay's header is (`read_u32_le` / + * `read_u16_le` in input_relay.rs) -- a big-endian magic is simply not this + * protocol's magic, so the packet is dropped on the `magic` counter with no + * reply. The nonce sits at offset 6, where an ordinary packet carries its + * session id; the relay echoes those bytes untouched and only rewrites the + * type (200 -> 201), so it comes back as sent. + */ +static void automatch_probe_pack(unsigned char *out, uint32_t nonce) +{ + const unsigned magic = g_am.probe_magic; + const int type = g_am.probe_type ? g_am.probe_type : 200; + memset(out, 0, AUTOMATCH_PROBE_LEN); + out[0] = (unsigned char)(magic & 0xFF); + out[1] = (unsigned char)((magic >> 8) & 0xFF); + out[2] = (unsigned char)((magic >> 16) & 0xFF); + out[3] = (unsigned char)((magic >> 24) & 0xFF); + out[4] = (unsigned char)(type & 0xFF); + out[5] = (unsigned char)((type >> 8) & 0xFF); + out[6] = (unsigned char)(nonce & 0xFF); + out[7] = (unsigned char)((nonce >> 8) & 0xFF); + out[8] = (unsigned char)((nonce >> 16) & 0xFF); + out[9] = (unsigned char)((nonce >> 24) & 0xFF); +} + +static int automatch_probe_send(void) +{ + struct addrinfo hints, *res = NULL; + unsigned char pkt[AUTOMATCH_PROBE_LEN]; + char portstr[16]; + int fd; + + if (!g_am.probe_host[0] || g_am.probe_port <= 0) return -1; + if (g_am.probe_sent_ms) return 0; /* one outstanding at a time */ + + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_DGRAM; + snprintf(portstr, sizeof(portstr), "%d", g_am.probe_port); + if (getaddrinfo(g_am.probe_host, portstr, &hints, &res) != 0 || !res) + return -1; + + fd = (int)socket(res->ai_family, res->ai_socktype, res->ai_protocol); + if (fd < 0) { freeaddrinfo(res); return -1; } + set_nonblock(fd); + + /* A fresh nonce per attempt, so a late reply to a previous probe cannot + * be timed against this one's clock and report an absurdly low number. */ + g_am.probe_nonce = (uint32_t)(lobby_mono_ms() * 2654435761u) ^ 0x9E3779B9u; + automatch_probe_pack(pkt, g_am.probe_nonce); + + if (sendto(fd, (const char *)pkt, (int)sizeof(pkt), 0, + res->ai_addr, (int)res->ai_addrlen) < 0) { + close(fd); + freeaddrinfo(res); + return -1; + } + freeaddrinfo(res); + + automatch_probe_close(); + g_am.probe_socket = fd; + g_am.probe_sent_ms = lobby_mono_ms(); + return 0; +} + +static void automatch_send_rtt(void) +{ + char msg[128]; + if (g_am.rtt_ms < 0 || g_am.rtt_reported) return; + snprintf(msg, sizeof(msg), + "{\"op\":\"automatch_rtt\",\"rtt_ms\":%d}", g_am.rtt_ms); + queue_send(msg); + g_am.rtt_reported = 1; +} + +/* Polled every pump. Times the 201 reply, or gives up after 2 s. */ +static void automatch_probe_poll(void) +{ + unsigned char buf[64]; + uint64_t now; + + if (g_am.probe_socket < 0 || !g_am.probe_sent_ms) return; + now = lobby_mono_ms(); + + for (;;) { + int n = (int)recv(g_am.probe_socket, (char *)buf, (int)sizeof(buf), 0); + if (n < 0) break; + if (n < 10) continue; + /* Match the nonce: the socket is unconnected and anything can arrive + * on it, and an unrelated packet timed as our reply is a wrong number + * reported as fact. */ + if (((uint32_t)buf[6] | (uint32_t)buf[7] << 8 | + (uint32_t)buf[8] << 16 | (uint32_t)buf[9] << 24) != g_am.probe_nonce) + continue; + g_am.rtt_ms = (int)(now - g_am.probe_sent_ms); + if (g_am.rtt_ms < 0) g_am.rtt_ms = 0; + if (g_am.rtt_ms > 2000) g_am.rtt_ms = 2000; /* the server clamps here too */ + fprintf(stderr, "psx_lobby: automatch probe %s:%d rtt=%d ms\n", + g_am.probe_host, g_am.probe_port, g_am.rtt_ms); + automatch_probe_close(); + /* Queued already? Then the ticket was enqueued on an unknown latency + * and the server is holding it out of pairing for the probe grace -- + * tell it now rather than letting the grace lapse. */ + if (g_am.state == PSX_LOBBY_AUTOMATCH_QUEUED) automatch_send_rtt(); + return; + } + if (now - g_am.probe_sent_ms > 2000) { + fprintf(stderr, "psx_lobby: automatch probe timed out (%s:%d) -- " + "queueing without a latency estimate\n", + g_am.probe_host, g_am.probe_port); + automatch_probe_close(); + } +} + +/* Walk to the next {...} in an array, copying it out. Returns 0 at ']'. */ +static int automatch_next_object(const char **pp, char *out, size_t cap) +{ + const char *p = *pp; + const char *start; + int depth = 0; + size_t n; + + while (*p && *p != '{') { + if (*p == ']') { *pp = p; return 0; } + ++p; + } + if (*p != '{') { *pp = p; return 0; } + start = p; + do { + if (*p == '{') ++depth; + else if (*p == '}') --depth; + ++p; + } while (*p && depth > 0); + n = (size_t)(p - start); + if (n >= cap) n = cap - 1; + memcpy(out, start, n); + out[n] = '\0'; + *pp = p; + return 1; +} + +/* `probe: { endpoint, magic, type }` -- where to measure the path. Published + * on both rulesets_ok and queued, and taken from whichever arrived last. */ +static void automatch_ingest_probe(const char *obj) +{ + char endpoint[160]; + char *colon; + endpoint[0] = '\0'; + json_get_str(obj, "endpoint", endpoint, sizeof(endpoint)); + /* Rightmost colon: an IPv6 literal has several, and the port is last. */ + colon = strrchr(endpoint, ':'); + if (!colon || !colon[1]) return; + *colon = '\0'; + snprintf(g_am.probe_host, sizeof(g_am.probe_host), "%s", endpoint); + g_am.probe_port = atoi(colon + 1); + g_am.probe_magic = (unsigned)json_get_int(obj, "magic", 0); + g_am.probe_type = json_get_int(obj, "type", 200); +} + +/* `titles: [ { ..., pool: N } ]` -- this host queues one title, so the first + * row is the one the player is waiting in. */ +static int automatch_first_pool(const char *json) +{ + const char *p = strstr(json, "\"titles\""); + char obj[512]; + if (!p) return g_am.pool; + p = strchr(p, '['); + if (!p) return g_am.pool; + ++p; + if (!automatch_next_object(&p, obj, sizeof(obj))) return 0; + return json_get_int(obj, "pool", 0); +} + +int psx_lobby_automatch_request_rulesets(void) +{ + char msg[256]; + char gn_esc[JSON_ESC_CAP(PSX_LOBBY_NAME_LEN)]; + const char *gn = g_lc.filter_game_name; + if (!g_lc.connected) return -1; + if (!gn || !gn[0]) return -1; + if (g_am.rulesets_in_flight) return 0; + json_escape(gn, gn_esc, sizeof(gn_esc)); + snprintf(msg, sizeof(msg), + "{\"op\":\"automatch_rulesets\",\"game_name\":\"%s\"}", gn_esc); + queue_send(msg); + g_am.rulesets_in_flight = 1; + return 0; +} + +int psx_lobby_automatch_available(void) +{ + /* Zero rulesets is a real answer and means the same as "no": this + * deployment has none loaded for this title. Not having ASKED yet is also + * no -- the button must not be offered on an assumption. */ + return g_am.have_rulesets && g_am.ruleset_count > 0; +} + +int psx_lobby_automatch_ruleset_count(void) { return g_am.ruleset_count; } + +int psx_lobby_automatch_ruleset_get(int index, PsxLobbyRuleset *out) +{ + if (!out || index < 0 || index >= g_am.ruleset_count) return 0; + *out = g_am.rulesets[index]; + return 1; +} + +int psx_lobby_automatch_queue(const char *ruleset_id, int mods_enabled, + const char *mod_exempt) +{ + char msg[2048]; + char exempt_json[768]; + char gn_esc[JSON_ESC_CAP(PSX_LOBBY_NAME_LEN)]; + char gv_esc[JSON_ESC_CAP(PSX_LOBBY_VERSION_LEN)]; + char rid_esc[JSON_ESC_CAP(PSX_LOBBY_RULESET_ID_LEN)]; + const char *rid = (ruleset_id && ruleset_id[0]) ? ruleset_id + : (g_am.ruleset_count > 0 ? g_am.rulesets[0].id : ""); + const char *disc_fp = psx_lobby_disc_fp(); + char rtt[48]; + + if (!g_lc.connected) return -1; + if (!rid[0]) return -1; + if (g_am.state == PSX_LOBBY_AUTOMATCH_QUEUED || + g_am.state == PSX_LOBBY_AUTOMATCH_FOUND || + g_am.state == PSX_LOBBY_AUTOMATCH_ACCEPTED || + g_am.queue_in_flight) + return -1; + /* The queue key REQUIRES a fingerprint: in `join` an empty one means + * "legacy host, no check", and a wildcard in a queue silently pairs a + * different dump against this one. Refuse here rather than let the server + * answer need_disc_fp, so the reason is available before the round trip. */ + if (!disc_fp || strlen(disc_fp) != 64) { + automatch_fail("this build cannot fingerprint its disc, so it cannot queue"); + return -1; + } + + json_escape(g_lc.filter_game_name, gn_esc, sizeof(gn_esc)); + json_escape(psx_lobby_game_version(), gv_esc, sizeof(gv_esc)); + json_escape(rid, rid_esc, sizeof(rid_esc)); + + /* The exemption evidence, as a JSON ARRAY of strings -- never a + * ';'-joined string, which is valid JSON that every reader using + * as_array() sees as empty and so fails open (recomp-net's + * chat_report.h records the instance this repo already paid for). */ + { + size_t o = 0; + const char *p = mod_exempt; + int first = 1; + exempt_json[o++] = '['; + while (p && *p) { + const char *end = p; + char entry[160]; + char esc[JSON_ESC_CAP(sizeof(entry))]; + size_t len; + while (*end && *end != ';' && *end != '\n') ++end; + len = (size_t)(end - p); + if (len && len < sizeof(entry)) { + memcpy(entry, p, len); + entry[len] = '\0'; + json_escape(entry, esc, sizeof(esc)); + if (o + strlen(esc) + 4 < sizeof(exempt_json)) { + if (!first) exempt_json[o++] = ','; + exempt_json[o++] = '"'; + memcpy(exempt_json + o, esc, strlen(esc)); + o += strlen(esc); + exempt_json[o++] = '"'; + first = 0; + } else { + /* Refuse rather than send a SHORT list: a truncated list + * of exemptions is one the server approves in full while + * the client relies on more than it declared. */ + automatch_fail("too many mod exemptions to declare"); + return -1; + } + } + p = *end ? end + 1 : end; + } + exempt_json[o++] = ']'; + exempt_json[o] = '\0'; + } + + /* Measure before queueing when we can: a client that probes first never + * waits out the server's probe grace at all. */ + if (g_am.rtt_ms < 0) automatch_probe_send(); + rtt[0] = '\0'; + if (g_am.rtt_ms >= 0) + snprintf(rtt, sizeof(rtt), ",\"rtt_ms\":%d", g_am.rtt_ms); + + /* One title: this host runs one game. A multi-title launcher sends + * several here, in preference order; the wire has always allowed it. */ + snprintf(msg, sizeof(msg), + "{\"op\":\"automatch_queue\",\"titles\":[{" + "\"game_name\":\"%s\",\"game_version\":\"%s\"," + "\"disc_fp\":\"%s\",\"ruleset_id\":\"%s\",\"max_slots\":2}]," + "\"mods_enabled\":%s,\"mod_exempt\":%s%s}", + gn_esc, gv_esc, disc_fp, rid_esc, + mods_enabled ? "true" : "false", exempt_json, rtt); + queue_send(msg); + flush_pending(); + g_am.error[0] = '\0'; + g_am.queue_in_flight = 1; + g_am.rtt_reported = (g_am.rtt_ms >= 0); + return 0; +} + +int psx_lobby_automatch_cancel(void) +{ + if (!g_lc.connected) return -1; + queue_send("{\"op\":\"automatch_cancel\"}"); + flush_pending(); + return 0; +} + +int psx_lobby_automatch_state(void) { return g_am.state; } +int psx_lobby_automatch_queued_secs(void) { return g_am.queued_secs; } +int psx_lobby_automatch_pool(void) { return g_am.pool; } + +int psx_lobby_automatch_found_get(PsxLobbyAutomatchFound *out) +{ + if (!out || g_am.state != PSX_LOBBY_AUTOMATCH_FOUND) return 0; + *out = g_am.found; + /* Recomputed on every read, so the caller can poll it each frame and get + * a live count. Never below 0: the offer is about to lapse, and a + * negative would draw as one. */ + if (g_am.found_deadline_ms) { + uint64_t now = lobby_mono_ms(); + out->accept_secs = now >= g_am.found_deadline_ms + ? 0 + : (int)((g_am.found_deadline_ms - now + 999ull) / 1000ull); + } + return 1; +} + +int psx_lobby_automatch_accept(int accept) +{ + char msg[192]; + char mid_esc[JSON_ESC_CAP(PSX_LOBBY_ID_LEN)]; + if (!g_lc.connected) return -1; + if (g_am.state != PSX_LOBBY_AUTOMATCH_FOUND) return -1; + /* The match id is echoed so a late answer to a LAPSED offer is discarded + * rather than applied to whatever offer is current by then. */ + json_escape(g_am.match_id, mid_esc, sizeof(mid_esc)); + snprintf(msg, sizeof(msg), + "{\"op\":\"automatch_accept\",\"match_id\":\"%s\"," + "\"accept\":%s}", mid_esc, accept ? "true" : "false"); + queue_send(msg); + flush_pending(); + if (accept) { + g_am.state = PSX_LOBBY_AUTOMATCH_ACCEPTED; + } else { + /* Declining ends the ticket. The strike is the server's to record. */ + automatch_reset_queue_state(); + } + return 0; +} + +int psx_lobby_automatch_room(void) { return g_am.in_automatch_room; } + +void psx_lobby_automatch_refuse_local(const char *why) +{ + automatch_fail(why); +} + +const char *psx_lobby_automatch_error(void) { return g_am.error; } + +/* Inbound automatch ops. Returns 1 when `op` was one of ours. */ +static int automatch_handle_op(const char *op, const char *json) +{ + if (strcmp(op, "automatch_rulesets_ok") == 0) { + const char *p2 = strstr(json, "\"rulesets\""); + char probe[256]; + int n = 0; + g_am.have_rulesets = 1; + g_am.rulesets_in_flight = 0; + g_am.ruleset_count = 0; + if (json_extract_object(json, "probe", probe, sizeof(probe))) + automatch_ingest_probe(probe); + if (p2) { + p2 = strchr(p2, '['); + if (p2) { + ++p2; + while (*p2 && n < PSX_LOBBY_MAX_RULESETS) { + char obj[1024]; + PsxLobbyRuleset *r = &g_am.rulesets[n]; + if (!automatch_next_object(&p2, obj, sizeof(obj))) break; + memset(r, 0, sizeof(*r)); + json_get_str(obj, "id", r->id, sizeof(r->id)); + json_get_str(obj, "label", r->label, sizeof(r->label)); + json_get_str(obj, "caps_summary", r->caps_summary, + sizeof(r->caps_summary)); + json_get_str(obj, "game_version", r->game_version, + sizeof(r->game_version)); + r->max_slots = json_get_int(obj, "max_slots", 2); + if (r->id[0]) ++n; + } + } + } + g_am.ruleset_count = n; + fprintf(stderr, "psx_lobby: automatch %d ruleset(s) for \"%s\"\n", + n, g_lc.filter_game_name); + /* Measure now rather than at queue time: a client that has already + * probed never waits out the server's probe grace. */ + if (n > 0 && g_am.rtt_ms < 0) automatch_probe_send(); + return 1; + } + if (strcmp(op, "automatch_queued") == 0) { + char probe[256]; + g_am.state = PSX_LOBBY_AUTOMATCH_QUEUED; + g_am.error[0] = '\0'; + g_am.queue_in_flight = 0; + json_get_str(json, "ticket_id", g_am.ticket_id, sizeof(g_am.ticket_id)); + g_am.queued_secs = 0; + g_am.pool = automatch_first_pool(json); + if (json_extract_object(json, "probe", probe, sizeof(probe))) + automatch_ingest_probe(probe); + if (g_am.rtt_ms < 0) automatch_probe_send(); + else automatch_send_rtt(); + fprintf(stderr, "psx_lobby: automatch queued (pool=%d)\n", g_am.pool); + return 1; + } + if (strcmp(op, "automatch_status") == 0) { + /* Pushed at most 1 Hz while queued. Not a state change: a status for + * a ticket we already gave up on must not resurrect the queue. */ + if (g_am.state != PSX_LOBBY_AUTOMATCH_QUEUED) return 1; + g_am.queued_secs = json_get_int(json, "queued_secs", g_am.queued_secs); + g_am.pool = automatch_first_pool(json); + return 1; + } + if (strcmp(op, "automatch_found") == 0) { + char opp[512]; + memset(&g_am.found, 0, sizeof(g_am.found)); + json_get_str(json, "match_id", g_am.match_id, sizeof(g_am.match_id)); + /* The opponent is an OBJECT: { handle, discord_username, country }. + * Read from the extracted object, not the whole frame -- "country" + * at top level would be found inside it either way, but "handle" is + * theirs and nothing else's. */ + if (json_extract_object(json, "opponent", opp, sizeof(opp))) { + json_get_str(opp, "handle", g_am.found.opponent, + sizeof(g_am.found.opponent)); + json_get_str(opp, "discord_username", g_am.found.opponent_username, + sizeof(g_am.found.opponent_username)); + json_get_str(opp, "country", g_am.found.opponent_country, + sizeof(g_am.found.opponent_country)); + } + json_get_str(json, "ruleset_id", g_am.found.ruleset_id, + sizeof(g_am.found.ruleset_id)); + json_get_str(json, "ruleset_label", g_am.found.ruleset_label, + sizeof(g_am.found.ruleset_label)); + g_am.found.est_rtt_ms = json_get_int(json, "est_rtt_ms", -1); + g_am.found.accept_secs = json_get_int(json, "accept_secs", 15); + g_am.found_deadline_ms = + lobby_mono_ms() + (uint64_t)(g_am.found.accept_secs > 0 + ? g_am.found.accept_secs : 0) * 1000ull; + g_am.state = PSX_LOBBY_AUTOMATCH_FOUND; + fprintf(stderr, "psx_lobby: automatch found opponent=\"%s\" " + "est_rtt=%d ms, %d s to answer\n", + g_am.found.opponent, g_am.found.est_rtt_ms, + g_am.found.accept_secs); + return 1; + } + if (strcmp(op, "automatch_accept_ok") == 0) { + /* The echo carries WHICH answer was acknowledged, and honouring it is + * not optional: setting ACCEPTED unconditionally reopened the gate on + * a player who had just declined. A declined ack ends the ticket + * here; the server's automatch_cancelled follows and is idempotent + * with this. */ + if (json_get_bool(json, "accept", 1)) { + if (g_am.state == PSX_LOBBY_AUTOMATCH_FOUND || + g_am.state == PSX_LOBBY_AUTOMATCH_ACCEPTED) + g_am.state = PSX_LOBBY_AUTOMATCH_ACCEPTED; + } else { + automatch_reset_queue_state(); + } + return 1; + } + if (strcmp(op, "automatch_requeue") == 0) { + /* The other side declined or let it lapse. Back to waiting, with the + * ticket intact -- this is not a failure and must not read as one. + * `queued:false` (lobby_limit) is the one case the ticket is gone. */ + if (json_get_bool(json, "queued", 1)) { + g_am.state = PSX_LOBBY_AUTOMATCH_QUEUED; + memset(&g_am.found, 0, sizeof(g_am.found)); + g_am.found_deadline_ms = 0; + g_am.pool = automatch_first_pool(json); + fprintf(stderr, "psx_lobby: automatch re-queued (the offer lapsed)\n"); + } else { + automatch_fail("The server is out of rooms -- try again shortly"); + } + return 1; + } + if (strcmp(op, "automatch_cancelled") == 0) { + char reason[32]; + int cooldown; + reason[0] = '\0'; + json_get_str(json, "reason", reason, sizeof(reason)); + cooldown = json_get_int(json, "cooldown_secs", 0); + automatch_reset_queue_state(); + /* A lapsed offer the player never answered is worth a line: the gate + * simply vanishes otherwise, and the cooldown it cost is invisible. */ + if (strcmp(reason, "timeout") == 0 || strcmp(reason, "declined") == 0) { + if (cooldown > 0) { + char line[96]; + snprintf(line, sizeof(line), + "%d Second Cooldown For Declining", cooldown); + automatch_fail(line); + } else if (strcmp(reason, "timeout") == 0) { + automatch_fail("The offer lapsed before you answered"); + } + } + return 1; + } + if (strcmp(op, "automatch_rtt_ok") == 0) { + return 1; /* acknowledgement only */ + } + return 0; +} + +/* An automatch refusal arrives as a plain `error`, so it has to be claimed + * or it would be filed as a join failure and the queue would sit waiting for + * a pairing that was never going to come. Only while an attempt is actually + * in flight: these codes are automatch's, but `error` is everyone's. + * Returns 1 when claimed. */ +static int automatch_claim_error(const char *code, const char *json) +{ + const char *why = NULL; + char line[160]; + if (!(g_am.queue_in_flight || + g_am.state == PSX_LOBBY_AUTOMATCH_QUEUED || + g_am.state == PSX_LOBBY_AUTOMATCH_FOUND || + g_am.state == PSX_LOBBY_AUTOMATCH_ACCEPTED)) + return 0; + if (!strcmp(code, "need_account")) why = "Sign in to use automatch"; + else if (!strcmp(code, "automatch_off")) why = "This server has no automatch queues"; + else if (!strcmp(code, "already_queued")) why = "This account is already in a queue"; + else if (!strcmp(code, "already_in_lobby")) why = "Leave the room first"; + else if (!strcmp(code, "unknown_ruleset")) why = "That queue type is gone -- refresh"; + else if (!strcmp(code, "need_disc_fp")) why = "The server needs a disc fingerprint this build did not send"; + else if (!strcmp(code, "version_not_pooled")) why = "This release is not the one this queue pools"; + else if (!strcmp(code, "mods_not_pooled")) why = "Turn off sim-affecting mods to queue"; + else if (!strcmp(code, "mod_not_approved")) why = "A mod exemption is not on this queue's approved list"; + else if (!strcmp(code, "slots_not_pooled")) why = "Automatch is two-player only"; + else if (!strcmp(code, "queue_full")) why = "The queue is full -- try again shortly"; + else if (!strcmp(code, "cooldown")) { + int retry = json_get_int(json, "retry_secs", 0); + if (retry > 0) + snprintf(line, sizeof(line), "%d Second Cooldown For Declining", retry); + else + snprintf(line, sizeof(line), "Cooldown For Declining"); + why = line; + } + if (!why) return 0; + automatch_fail(why); + return 1; +} + static void handle_server_json(const char *json); /* Parse complete unmasked server text frames from ws_pending; leave remainder. */ @@ -1807,7 +2520,7 @@ static void drain_ws_pending(void) } static void chat_push(const char *player_id, const char *from, const char *text, - const char *mid, int is_system) + const char *mid, const char *account, int is_system) { PsxLobbyChatMsg *m; int idx; @@ -1826,6 +2539,8 @@ static void chat_push(const char *player_id, const char *from, const char *text, snprintf(m->text, sizeof(m->text), "%s", text); snprintf(m->mid, sizeof(m->mid), "%s", (mid && !is_system) ? mid : ""); + snprintf(m->account, sizeof(m->account), "%s", + (account && !is_system) ? account : ""); /* Masked on arrival, whatever relayed it: the server already did this, * an older server did not, and the rule is that nothing unmasked is * ever shown. */ @@ -1837,7 +2552,7 @@ static void chat_push(const char *player_id, const char *from, const char *text, } static void schat_push(const char *player_id, const char *from, const char *text, - const char *mid) + const char *mid, const char *account) { PsxLobbyChatMsg *m; int idx; @@ -1855,6 +2570,7 @@ static void schat_push(const char *player_id, const char *from, const char *text snprintf(m->from, sizeof(m->from), "%s", from ? from : ""); snprintf(m->text, sizeof(m->text), "%s", text); snprintf(m->mid, sizeof(m->mid), "%s", mid ? mid : ""); + snprintf(m->account, sizeof(m->account), "%s", account ? account : ""); (void)rnet_chat_filter_apply(m->text, sizeof(m->text)); m->is_local = (g_lc.player_id[0] && player_id && strcmp(player_id, g_lc.player_id) == 0) ? 1 : 0; @@ -1906,6 +2622,9 @@ static void handle_server_json(const char *json) json_get_str(json, "op", op, sizeof(op)); if (strcmp(op, "welcome") == 0) { json_get_str(json, "player_id", g_lc.player_id, sizeof(g_lc.player_id)); + /* A fresh socket: the server holds the ticket and the ruleset answer + * per connection, so neither survives from the last one. */ + automatch_on_connection_reset(); { /* Say who we are AND what we are playing in the first message: * the server scopes players-online and server chat by title, and @@ -2158,6 +2877,15 @@ static void handle_server_json(const char *json) } if (strcmp(op, "joined") == 0) { psx_lobby_chat_clear(); + /* A ticket that reached a room is spent: `joined` is that moment for + * automatch (AUTOMATCH.md 8). Recorded before the reset below, which + * erases the evidence -- ACCEPTED (or FOUND, if the pair resolved in + * the same breath) is the only signal that this seat came from a + * queue rather than from somebody's room. */ + g_am.in_automatch_room = + (g_am.state == PSX_LOBBY_AUTOMATCH_ACCEPTED || + g_am.state == PSX_LOBBY_AUTOMATCH_FOUND); + automatch_reset_queue_state(); g_lc.in_lobby = 1; g_lc.is_host = 0; g_lc.join.ok = 1; @@ -2308,15 +3036,18 @@ static void handle_server_json(const char *json) char from_id[PSX_LOBBY_ID_LEN]; char from[PSX_LOBBY_NAME_LEN]; char mid[40]; + char account[PSX_LOBBY_ID_LEN]; text[0] = '\0'; from_id[0] = '\0'; from[0] = '\0'; mid[0] = '\0'; + account[0] = '\0'; json_get_str(json, "text", text, sizeof(text)); json_get_str(json, "from_player_id", from_id, sizeof(from_id)); json_get_str(json, "from", from, sizeof(from)); json_get_str(json, "mid", mid, sizeof(mid)); - schat_push(from_id, from, text, mid); + json_get_str(json, "from_account", account, sizeof(account)); + schat_push(from_id, from, text, mid, account); return; } if (strcmp(op, "chat") == 0) { @@ -2324,15 +3055,19 @@ static void handle_server_json(const char *json) char from_id[PSX_LOBBY_ID_LEN]; char from[PSX_LOBBY_NAME_LEN]; char mid[40]; + char account[PSX_LOBBY_ID_LEN]; text[0] = '\0'; from_id[0] = '\0'; from[0] = '\0'; mid[0] = '\0'; + account[0] = '\0'; json_get_str(json, "text", text, sizeof(text)); json_get_str(json, "from_player_id", from_id, sizeof(from_id)); json_get_str(json, "from", from, sizeof(from)); json_get_str(json, "mid", mid, sizeof(mid)); - chat_push(from_id, from, text, mid, json_get_bool(json, "system", 0)); + json_get_str(json, "from_account", account, sizeof(account)); + chat_push(from_id, from, text, mid, account, + json_get_bool(json, "system", 0)); return; } if (strcmp(op, "signal") == 0) { @@ -2364,11 +3099,17 @@ static void handle_server_json(const char *json) (void)flag; return; } + if (automatch_handle_op(op, json)) { + return; + } if (strcmp(op, "error") == 0) { char code[64]; json_get_str(json, "code", code, sizeof(code)); strncpy(g_lc.join.last_error, code, sizeof(g_lc.join.last_error) - 1); g_lc.join.last_error[sizeof(g_lc.join.last_error) - 1] = '\0'; + if (automatch_claim_error(code, json)) { + return; + } /* Create/join failures are fatal to the seat. In-lobby ops (kick/move * on an older server, not_host, …) must not clear join.ok or the room * looks abandoned after a rejected host action. */ @@ -2388,6 +3129,10 @@ static void handle_server_json(const char *json) if (strcmp(op, "lobby_closed") == 0 || strcmp(op, "left") == 0 || strcmp(op, "kicked") == 0) { psx_lobby_chat_clear(); + /* Leaving a room must never leave a queue ticket looking live, and + * no seat means no automatch room either. */ + automatch_reset_queue_state(); + g_am.in_automatch_room = 0; g_lc.swap_in_valid = 0; g_lc.swap_out = 0; g_lc.ice_rtt_suspended = 0; @@ -2805,6 +3550,7 @@ void psx_lobby_disconnect(void) { /* Never block the UI on DNS/connect — cancel and let pump reap. */ lobby_cancel_connect_async(); + automatch_on_connection_reset(); g_lc.ice_rtt_suspended = 0; lobby_ice_rtt_close(); @@ -3153,6 +3899,7 @@ static void lobby_ice_rtt_tick(void) void psx_lobby_pump(void) { char buf[4096]; + automatch_probe_poll(); #if defined(_WIN32) int n; #else