diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 9242daa1..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; /* @@ -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 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index cfcf7534..4ed1904b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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) diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 129bf023..4dc5d738 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 #include @@ -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];