Merge pull request #351 from RetroPortingToolKit/feat/lobby-parity

Feat/lobby parity
This commit is contained in:
Alex Vanderveen
2026-09-12 20:06:06 -04:00
committed by GitHub
3 changed files with 1221 additions and 11 deletions
+158
View File
@@ -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;
/*
@@ -128,6 +136,18 @@ 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];
/* 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;
@@ -400,6 +420,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,
@@ -438,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
+242 -3
View File
@@ -57,6 +57,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 "recomp_net/auth.h"
#if defined(PSX_HAS_RECOMP_NET)
#include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */
#endif
@@ -10023,6 +10024,152 @@ 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 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); }
/* 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;
@@ -10046,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;
@@ -10071,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;
@@ -10228,6 +10391,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. */
ae_np_account_sync();
}
int ae_np_connect(void*) {
@@ -10805,6 +10971,26 @@ namespace {
}
void ae_np_pump(void*) {
/* 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. */
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();
@@ -10849,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);
@@ -10893,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;
@@ -11451,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()) {
@@ -11512,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;
}
@@ -12061,6 +12258,43 @@ 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
#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;
g_lnch_netplay_callbacks.seat_swap_incoming = ae_np_seat_swap_incoming;
@@ -15611,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;
@@ -15788,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)
+821 -8
View File
@@ -1,4 +1,5 @@
#include "psx_lobby_client.h"
#include "recomp_net/chat_report.h"
#include <ctype.h>
#include <errno.h>
@@ -122,6 +123,25 @@ 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_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)
@@ -153,6 +173,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 "recomp_net/auth.h" /* optional Discord session for `hello` */
#include "host_time.h"
#if defined(_WIN32)
@@ -1346,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
@@ -1690,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 =
@@ -1756,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. */
@@ -1802,7 +2520,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, const char *account, int is_system)
{
PsxLobbyChatMsg *m;
int idx;
@@ -1819,6 +2537,10 @@ 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 : "");
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. */
@@ -1829,7 +2551,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, const char *account)
{
PsxLobbyChatMsg *m;
int idx;
@@ -1846,6 +2569,8 @@ 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 : "");
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;
@@ -1868,12 +2593,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 = 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));
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();
}
@@ -1884,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
@@ -2136,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;
@@ -2285,26 +3035,39 @@ 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];
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));
schat_push(from_id, from, text);
json_get_str(json, "mid", mid, sizeof(mid));
json_get_str(json, "from_account", account, sizeof(account));
schat_push(from_id, from, text, mid, account);
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];
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));
chat_push(from_id, from, text, json_get_bool(json, "system", 0));
json_get_str(json, "mid", mid, sizeof(mid));
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) {
@@ -2336,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. */
@@ -2360,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;
@@ -2777,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();
@@ -3125,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
@@ -3693,6 +4468,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];