// SPDX-License-Identifier: GPL-3.0-or-later // The Hunter's Dream backend: settings, the account, the session service client, the dispatcher, // the peer table, the event poller and the keepalive. Adapted from droogie/bbhost // src/net/{http,account,session}.cpp and src/engine/summon_invite.cpp (566184a). #include "httplib.h" #include "thd.h" #include "thd_internal.h" #include "windows_trust.h" #include "../room/room_service.h" #include "../../shim/engine/online_gate.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #else #include #endif namespace BbEngine { bool host_read_safe(const void* p, void* out, std::size_t n); } namespace BbThd { // ---- log, settings ------------------------------------------------------------------------------ void Log(const char* format, ...) { char text[1024]; va_list args; va_start(args, format); std::vsnprintf(text, sizeof(text), format, args); va_end(args); std::printf("THD: %s\n", text); } namespace { std::string Env(const char* name) { const char* v = std::getenv(name); return v ? v : ""; } std::string HostOf(std::string base) { if (const auto s = base.find("://"); s != std::string::npos) base = base.substr(s + 3); if (const auto slash = base.find('/'); slash != std::string::npos) base = base.substr(0, slash); if (const auto colon = base.find(':'); colon != std::string::npos) base = base.substr(0, colon); return base; } } // namespace bool Trace() { static const bool on = Env("BB_THD_TRACE") == "1"; return on; } const Config& Cfg() { static const Config c = [] { Config c; c.np_base = Env("BB_THD_SERVER"); if (c.np_base.empty()) c.np_base = "https://thehuntersdream.com:18671"; while (!c.np_base.empty() && c.np_base.back() == '/') c.np_base.pop_back(); c.auth_base = Env("BB_THD_AUTH"); if (c.auth_base.empty()) c.auth_base = "https://thehuntersdream.com"; while (!c.auth_base.empty() && c.auth_base.back() == '/') c.auth_base.pop_back(); c.stun = Env("BB_THD_STUN"); c.ruleset = Env("BB_THD_RULESET"); if (c.ruleset.empty()) c.ruleset = "vanilla"; // "BloodBorneButPc 1.2" -> "BloodBorneButPc/1.2": the server tells clients apart by it. c.client = Env("BB_CLIENT_VERSION"); if (c.client.empty()) c.client = "BloodBorneButPc"; std::replace(c.client.begin(), c.client.end(), ' ', '/'); c.p2p_addr = Env("BB_THD_P2P_ADDR"); const std::string port = Env("BB_P2P_PORT"); c.p2p_port = port.empty() ? 3658 : std::atoi(port.c_str()); if (c.p2p_port <= 0 || c.p2p_port > 65535) c.p2p_port = 3658; if (BbRoom::Active()) { c.np_base = "room://127.0.0.1"; c.stun = "off"; c.p2p_addr = "127.0.0.1"; } c.verify_tls = Env("BB_THD_VERIFY_TLS") != "0"; return c; }(); return c; } bool Enabled() { static const bool on = Env("BB_ONLINE") == "1" && (Env("BB_NET_BACKEND") == "thd" || BbRoom::Active()); return on; } // ---- the account ---------------------------------------------------------------------------------- namespace { std::mutex g_account_mu; std::string g_name, g_token; bool g_refused = false; std::string g_refusal_banner; std::chrono::steady_clock::time_point g_refusal_at; // Read on first use: the game may ask for its NP state before the online start. void LoadAccountLocked() { static bool loaded = false; if (loaded) return; loaded = true; g_name = Env("BB_THD_ONLINE_ID").substr(0, 16); g_token = Env("BB_THD_TOKEN"); if (BbRoom::Active()) { g_name = BbRoom::NetworkId(); g_token = g_name.empty() ? "" : "room"; } } } // namespace std::string AccountName() { std::lock_guard lk(g_account_mu); LoadAccountLocked(); return g_name; } std::string AccountToken() { std::lock_guard lk(g_account_mu); LoadAccountLocked(); return g_token; } bool SignedIn() { std::lock_guard lk(g_account_mu); LoadAccountLocked(); return !g_name.empty() && !g_token.empty() && !g_refused; } bool Refused() { std::lock_guard lk(g_account_mu); return g_refused; } void MarkRefused(const std::string& reason) { std::string name; { std::lock_guard lk(g_account_mu); if (g_refused) return; g_refused = true; name = g_name; } Log("the server refused this PC's sign-in as %s (%s): sign in again on the launcher's Online " "page; until then nobody can summon or invite this player", name.c_str(), reason.c_str()); std::string banner = "Sign-in expired: sign in again on the launcher's Online page"; if (name.empty()) banner = "Online play needs a sign-in: the launcher's Online page"; if (reason.find("banned") != std::string::npos) { banner = reason; banner[0] = static_cast(std::toupper(static_cast(banner[0]))); } std::lock_guard lk(g_account_mu); g_refusal_banner = banner; g_refusal_at = std::chrono::steady_clock::now(); } bool RefusalNotice(std::string& text) { std::lock_guard lk(g_account_mu); if (g_refusal_banner.empty() || std::chrono::steady_clock::now() - g_refusal_at > std::chrono::seconds(15)) return false; text = g_refusal_banner; return true; } std::string OnlineId() { const std::string name = AccountName(); return name.empty() ? "Player" : name; } void SignInCheck(); void Initialize() { const Config& c = Cfg(); if (BbRoom::Active()) { if (SignedIn()) BbRoom::Start(c.p2p_port); else Log("seamless co-op needs a player name (the launcher's Seamless page): the game stays offline"); return; } if (SignedIn()) { Log("The Hunter's Dream as %s: session service %s, P2P port %d", AccountName().c_str(), c.np_base.c_str(), c.p2p_port); std::thread(SignInCheck).detach(); } else { Log("not signed in to The Hunter's Dream (the launcher's Online page): the game stays offline"); } } // ---- JSON --------------------------------------------------------------------------------------- std::string StrOf(const Json& o, const char* key, const std::string& dflt) { if (!o.is_object()) return dflt; const auto it = o.find(key); if (it == o.end()) return dflt; if (it->is_string()) return it->get(); if (it->is_number_integer()) return std::to_string(it->get()); if (it->is_number()) return std::to_string(static_cast(it->get())); return dflt; } long long IntOf(const Json& o, const char* key, long long dflt) { if (!o.is_object()) return dflt; const auto it = o.find(key); if (it == o.end()) return dflt; if (it->is_number_integer()) return it->get(); if (it->is_number()) return static_cast(it->get()); if (it->is_string()) return std::strtoll(it->get().c_str(), nullptr, 10); if (it->is_boolean()) return it->get() ? 1 : 0; return dflt; } // ---- the session service client ----------------------------------------------------------------- namespace { struct HttpResult { int status = 0; std::string body; std::string error; }; // One keep-alive connection per thread and server: the poller asks four times a second. std::mutex g_poll_client_mu; httplib::Client* g_poll_client = nullptr; std::atomic g_pollrun{false}; HttpResult Perform(const std::string& base, const std::string& path, const std::string* post, int timeout_ms) { thread_local std::map> clients; auto& cli = clients[base]; const auto timeout = std::chrono::milliseconds(timeout_ms); auto open = [&] { cli = std::make_unique(base); cli->set_keep_alive(true); cli->set_follow_location(false); #ifdef CPPHTTPLIB_OPENSSL_SUPPORT cli->enable_server_certificate_verification(Cfg().verify_tls); if (Cfg().verify_tls) bbnet::TrustWindowsRoots(*cli, base); #endif }; const bool reused = cli != nullptr; if (!cli) open(); httplib::Headers headers{{"Accept", "application/json"}, {"User-Agent", Cfg().client}, {"X-BBHost-Ruleset", Cfg().ruleset}}; if (const std::string token = AccountToken(); !token.empty()) { headers.emplace("X-BB-Token", token); } auto send = [&] { // Connecting stays at a few seconds; only the read waits as long as the request asks (a held poll). cli->set_connection_timeout(std::min(timeout, std::chrono::milliseconds(5000))); cli->set_read_timeout(timeout); cli->set_write_timeout(timeout); return post ? cli->Post(path, headers, *post, "application/json") : cli->Get(path, headers); }; HttpResult r; const bool poll = path == "/np/events/poll"; if (poll) { std::lock_guard lk(g_poll_client_mu); g_poll_client = cli.get(); } const auto sent_at = std::chrono::steady_clock::now(); httplib::Result res = send(); if (poll) { std::lock_guard lk(g_poll_client_mu); g_poll_client = nullptr; } const bool quick = std::chrono::steady_clock::now() - sent_at < std::chrono::milliseconds(1000); // A kept-alive connection the server closed meanwhile fails on its first read or write // ("Failed to read connection"): once more on a new one, as bbhost's fresh request would. // A read that ran into its timeout is not that: the server had the request. const bool kept = path == "/np/events/poll" || path == "/np/events/ack"; if (!res && kept && reused && quick && (!poll || g_pollrun.load()) && (res.error() == httplib::Error::Read || res.error() == httplib::Error::Write)) { open(); res = send(); } if (!res) { r.error = httplib::to_string(res.error()); cli.reset(); // a fresh connection next time return r; } r.status = res->status; r.body = res->body; return r; } bool ParseReply(const std::string& what, const HttpResult& r, Json& out, std::string& error) { static std::atomic logs{0}; if (!r.error.empty()) { error = r.error; } else if (r.status < 200 || r.status >= 300) { error = "HTTP " + std::to_string(r.status); // 401 is the server refusing the sign-in, not a passing failure. if (r.status == 401) { const Json body = Json::parse(r.body, nullptr, false); const std::string why = StrOf(body, "Message"); MarkRefused(why.empty() ? "HTTP 401" : why); } } else { out = Json::parse(r.body, nullptr, false); if (!out.is_discarded()) return true; error = "bad JSON"; } if (logs.fetch_add(1) < 16) Log("np: %s -> %s", what.c_str(), error.c_str()); return false; } } // namespace std::string NpBase() { return Cfg().np_base; } bool NpPost(const std::string& path, const Json& body, Json& out, std::string& error, int timeout_ms) { if (BbRoom::Active()) return BbRoom::Call(path, body, out, error, timeout_ms); const std::string text = body.dump(); return ParseReply("POST " + path, Perform(Cfg().np_base, path, &text, timeout_ms), out, error); } bool NpGet(const std::string& path, Json& out, std::string& error, int timeout_ms) { if (BbRoom::Active()) return BbRoom::Call(path, Json::object(), out, error, timeout_ms); return ParseReply("GET " + path, Perform(Cfg().np_base, path, nullptr, timeout_ms), out, error); } void AddGameHeaders(std::vector>& headers) { if (BbRoom::Active()) return; // The game's own traffic carries the token too, so the server's game sessions map to the // account, and the ruleset, so its messages and bloodstains stay among the same rules. if (const std::string token = AccountToken(); !token.empty()) { headers.emplace_back("X-BB-Token", token); } headers.emplace_back("X-BBHost-Ruleset", Cfg().ruleset); for (auto& [name, value] : headers) { if (name.size() == 10 && std::equal(name.begin(), name.end(), "user-agent", [](char a, char b) { return std::tolower(static_cast(a)) == b; })) { value += " " + Cfg().client; break; } } } // ---- the dispatcher ------------------------------------------------------------------------------- namespace { using Clock = std::chrono::steady_clock; struct Due { Clock::time_point at; int prio; std::uint64_t seq; std::function fn; bool operator<(const Due& o) const { if (at != o.at) return at < o.at; if (prio != o.prio) return prio < o.prio; return seq < o.seq; } }; std::mutex g_dmu; std::condition_variable g_dcv; std::deque g_due; // sorted std::uint64_t g_seq = 0; bool g_dispatcher = false; void* PS4_SYSV_ABI DispatcherMain(void*) { for (;;) { std::function fn; { std::unique_lock lk(g_dmu); for (;;) { if (!g_due.empty()) { if (g_due.front().at <= Clock::now()) { fn = std::move(g_due.front().fn); g_due.pop_front(); break; } g_dcv.wait_until(lk, g_due.front().at); } else { g_dcv.wait(lk); } } } try { fn(); } catch (const std::exception& e) { Log("dispatcher: %s", e.what()); } catch (...) { Log("dispatcher: unknown exception"); } } return nullptr; } } // namespace void DispatchAfter(int delay_ms, Prio prio, std::function fn) { std::lock_guard lk(g_dmu); if (!g_dispatcher) { // A guest thread: the game's callbacks run on it, as on the console's NP threads. void* thread = nullptr; if (runtime_thread_spawn(&thread, DispatcherMain, nullptr, 1 << 20, "THD:np") == 0) { g_dispatcher = true; } else { Log("dispatcher: the NP thread did not start; online callbacks will not arrive"); } } Due d{Clock::now() + std::chrono::milliseconds(delay_ms < 0 ? 0 : delay_ms), static_cast(prio), g_seq++, std::move(fn)}; auto it = g_due.begin(); while (it != g_due.end() && *it < d) ++it; g_due.insert(it, std::move(d)); g_dcv.notify_all(); } std::int64_t NowMs() { return std::chrono::duration_cast(Clock::now().time_since_epoch()) .count(); } // ---- our endpoint ----------------------------------------------------------------------------------- namespace { std::atomic g_p2p_bound{0}; std::atomic g_stun_rtt_us{0}; } // namespace std::string LocalAddrText() { static const std::string addr = [] { if (!Cfg().p2p_addr.empty()) return Cfg().p2p_addr; const std::string lan = DetectLanAddress(HostOf(Cfg().np_base)); return lan.empty() ? std::string("127.0.0.1") : lan; }(); return addr; } int P2pPort() { const int bound = g_p2p_bound.load(std::memory_order_relaxed); return bound > 0 ? bound : Cfg().p2p_port; } void SetP2pPortBound(int port) { g_p2p_bound.store(port, std::memory_order_relaxed); if (BbRoom::Active()) BbRoom::SetGamePort(port); } int StunRttUs() { return g_stun_rtt_us.load(); } // ---- the peer table ----------------------------------------------------------------------------------- namespace { std::mutex g_pmu; std::map g_peers; std::uint32_t AddrOf(const std::string& text) { in_addr a{}; if (text.empty() || inet_pton(AF_INET, text.c_str(), &a) != 1) return 0; return a.s_addr; } // A peer with a new address: probes toward it so our NAT lets its datagrams in. void PunchIfNew(const Peer& before, const Peer& after) { if (after.online_id == OnlineId() || !after.addr || !after.port) return; if (before.addr == after.addr && before.port == after.port && before.local_addr == after.local_addr && before.local_port == after.local_port) { return; } P2pPunch(after.online_id.c_str(), after.addr, after.port, after.local_addr, after.local_port); } } // namespace void PeersClear() { std::lock_guard lk(g_pmu); g_peers.clear(); } void PeersUpsert(const Peer& p) { Peer before; { std::lock_guard lk(g_pmu); Peer& slot = g_peers[p.member_id]; before = slot; const unsigned conn = p.conn_id ? p.conn_id : slot.conn_id; const int sig = p.sig_status ? p.sig_status : slot.sig_status; slot = p; slot.conn_id = conn; slot.sig_status = sig; } PunchIfNew(before, p); } bool PeersGet(std::uint16_t member_id, Peer* out) { std::lock_guard lk(g_pmu); const auto it = g_peers.find(member_id); if (it == g_peers.end()) return false; if (out) *out = it->second; return true; } bool PeersFindOnline(const std::string& id, Peer* out) { std::lock_guard lk(g_pmu); for (const auto& [m, p] : g_peers) { if (p.online_id == id) { if (out) *out = p; return true; } } return false; } void PeersSetConn(std::uint16_t member_id, unsigned conn_id, int sig_status) { std::lock_guard lk(g_pmu); const auto it = g_peers.find(member_id); if (it == g_peers.end()) return; it->second.conn_id = conn_id; it->second.sig_status = sig_status; } void PeersErase(std::uint16_t member_id) { std::lock_guard lk(g_pmu); g_peers.erase(member_id); } std::vector PeersAll() { std::lock_guard lk(g_pmu); std::vector v; for (const auto& [m, p] : g_peers) v.push_back(p); return v; } int PeersFromMembers(const Json& members) { if (!members.is_array()) return 0; int n = 0; for (const Json& m : members) { Peer p; p.member_id = static_cast(IntOf(m, "MemberId", 0)); if (!p.member_id) continue; p.online_id = StrOf(m, "OnlineId"); p.addr = AddrOf(StrOf(m, "Addr")); p.port = static_cast(IntOf(m, "Port", 0)); p.local_addr = AddrOf(StrOf(m, "LocalAddr")); p.local_port = static_cast(IntOf(m, "LocalPort", 0)); // The server's own rule, applied here too: a STUN-mapped address that differs from the // peer's local one means a NAT in between, and only the mapped one reaches the peer. const std::uint32_t mapped = AddrOf(StrOf(m, "MappedAddr")); const long long mapped_port = IntOf(m, "MappedPort", 0); if (mapped && mapped_port > 0 && mapped_port <= 65535 && mapped != p.local_addr && mapped != p.addr) { p.addr = mapped; p.port = static_cast(mapped_port); } if (!p.addr) { p.addr = p.local_addr; p.port = p.local_port; } PeersUpsert(p); ++n; } return n; } Peer PeersProvisional(const std::string& id, std::uint32_t addr, std::uint16_t port) { Peer before, after; { std::lock_guard lk(g_pmu); bool found = false; for (auto& [m, p] : g_peers) { if (p.online_id == id && m >= 0xff00) { before = p; p.addr = addr; p.port = port; after = p; found = true; break; } } if (!found) { std::uint16_t m = 0xff00; while (g_peers.count(m)) ++m; after.member_id = m; after.online_id = id; after.addr = addr; after.port = port; g_peers[m] = after; } } PunchIfNew(before, after); return after; } // ---- STUN: the reflexive address of our P2P port --------------------------------------------------- namespace { struct Mapped { std::mutex mu; std::string addr; int port = 0; std::int64_t at_ms = 0; bool asked = false; }; Mapped g_mapped; void StunServer(std::string* host, std::uint16_t* port) { std::string spec = Cfg().stun; if (spec.empty()) spec = HostOf(Cfg().np_base); *port = 3478; if (const auto colon = spec.find(':'); colon != std::string::npos) { *port = static_cast(std::atoi(spec.c_str() + colon + 1)); spec = spec.substr(0, colon); } *host = spec; } // At context_start, then when the answer is over two minutes old (the keepalive asks every 15 s). void MappedRefresh(bool force) { { std::lock_guard lk(g_mapped.mu); if (!force && g_mapped.asked && NowMs() - g_mapped.at_ms < 120000) return; g_mapped.asked = true; g_mapped.at_ms = NowMs(); } if (Cfg().stun == "off") return; std::string host; std::uint16_t sport = 0; StunServer(&host, &sport); if (host.empty()) return; std::uint32_t addr = 0; std::uint16_t port = 0; bool ok = false; Relay relay; for (int attempt = 0; attempt < 3 && !ok; ++attempt) { const auto t0 = Clock::now(); ok = P2pStun(host.c_str(), sport, 1000, &addr, &port, &relay); if (ok) { g_stun_rtt_us = static_cast( std::chrono::duration_cast(Clock::now() - t0).count()); } } std::lock_guard lk(g_mapped.mu); if (!ok) { if (!g_mapped.addr.empty()) return; // keep the last answer Log("stun: no answer from %s:%u; the server gets the LAN address only", host.c_str(), sport); return; } char text[32]; std::snprintf(text, sizeof(text), "%u.%u.%u.%u", addr & 0xff, (addr >> 8) & 0xff, (addr >> 16) & 0xff, addr >> 24); if (g_mapped.addr != text || g_mapped.port != port) { Log("stun: %s:%u sees our P2P port %d as %s:%u", host.c_str(), sport, P2pPort(), BbNet::MaskIpText(text).c_str(), port); } g_mapped.addr = text; g_mapped.port = port; } Json OurEndpoint() { MappedRefresh(false); Json o = Json::object(); o["OnlineId"] = OnlineId(); o["LocalAddr"] = LocalAddrText(); o["LocalPort"] = P2pPort(); o["PublicAddr"] = LocalAddrText(); o["PublicPort"] = P2pPort(); std::lock_guard lk(g_mapped.mu); o["MappedAddr"] = g_mapped.addr; o["MappedPort"] = g_mapped.port; return o; } std::mutex g_roommu; std::string g_room_sid; int g_room_mid = 0; } // namespace // ---- the session service's calls ---------------------------------------------------------------- bool ServerSignalingResolve(const std::string& id, std::uint32_t* addr, std::uint16_t* port, std::string& error) { Json body = {{"OnlineId", id}}; Json reply; if (!NpPost("/np/signaling/resolve", body, reply, error)) return false; if (IntOf(reply, "ResKind", -1) != 0) { error = "not registered"; return false; } const std::uint32_t a = AddrOf(StrOf(reply, "Addr")); const long long p = IntOf(reply, "Port", 0); if (!a || p <= 0 || p > 65535) { error = "no address"; return false; } if (addr) *addr = a; if (port) *port = static_cast(p); return true; } void SignInCheck() { static std::atomic checked{false}; if (checked.exchange(true)) return; const std::string token = AccountToken(); if (token.empty() || AccountName().empty()) return; const std::string body = Json{{"Token", token}}.dump(); const HttpResult r = Perform(Cfg().auth_base, "/auth/whoami", &body, 5000); const long status = r.error.empty() ? r.status : 0; if (!r.error.empty() || status <= 0) { Log("account: the sign-in could not be checked: %s", r.error.empty() ? "no answer" : r.error.c_str()); return; } const Json v = Json::parse(r.body, nullptr, false); const auto kind = v.is_object() ? v.find("ResKind") : v.end(); if (!v.is_object() || kind == v.end() || !kind->is_number()) { Log("account: the sign-in could not be checked: HTTP %ld without ResKind", status); return; } if (kind->get() != 0) { if (status != 200 && status != 401) { Log("account: the sign-in could not be checked: HTTP %ld", status); return; } const std::string why = StrOf(v, "Message"); MarkRefused(why.empty() ? "refused" : why); return; } const std::string id = StrOf(v, "OnlineId"); if (status < 200 || status >= 300 || id.empty()) { Log("account: the sign-in could not be checked: HTTP %ld without OnlineId", status); return; } Log("account: sign-in checked: the server knows this PC as %s", id.c_str()); } bool ServerContextStart(Json& reply, std::string& error) { BbOnlineGate::MarkLive("The Hunter's Dream"); MappedRefresh(true); Json body = Json::object(); body["OnlineId"] = OnlineId(); body["SignalingAddr"] = LocalAddrText(); body["SignalingPort"] = P2pPort(); { std::lock_guard lk(g_mapped.mu); body["MappedAddr"] = g_mapped.addr; body["MappedPort"] = g_mapped.port; } return NpPost("/mp/matching2/context_start", body, reply, error); } bool ServerCreateRoom(int max_members, const Json& extra, Json& reply, std::string& error) { Json body = OurEndpoint(); body["MaxMembers"] = max_members; if (extra.is_object()) { for (const auto& [k, v] : extra.items()) body[k] = v; } return NpPost("/mp/matching2/create_room", body, reply, error); } bool ServerJoinRoom(std::uint64_t room_id, Json& reply, std::string& error) { Json body = OurEndpoint(); body["RoomId"] = static_cast(room_id); return NpPost("/mp/matching2/join_room", body, reply, error); } bool ServerLeaveRoom(const std::string& session_id, int member_id, Json& reply, std::string& error) { Json body = {{"SessionId", session_id}, {"MemberId", member_id}}; return NpPost("/mp/matching2/leave_room", body, reply, error); } int ServerHeartbeat(const std::string& session_id, int member_id) { Json body = {{"SessionId", session_id}, {"MemberId", member_id}}; Json reply; std::string err; if (!NpPost("/mp/matching2/heartbeat", body, reply, err, 3000)) return -1; return IntOf(reply, "InRoom", 1) == 0 ? 0 : 1; } bool ServerKickMember(const std::string& session_id, int member_id, int kicker_id, const std::uint8_t* opt, std::size_t opt_len, std::string& error) { static const char* const b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string enc; opt_len = std::min(opt_len, 16); for (std::size_t i = 0; i < opt_len; i += 3) { const std::uint32_t v = (static_cast(opt[i]) << 16) | (i + 1 < opt_len ? static_cast(opt[i + 1]) << 8 : 0u) | (i + 2 < opt_len ? opt[i + 2] : 0u); enc += b64[(v >> 18) & 63]; enc += b64[(v >> 12) & 63]; enc += i + 1 < opt_len ? b64[(v >> 6) & 63] : '='; enc += i + 2 < opt_len ? b64[v & 63] : '='; } Json body = {{"SessionId", session_id}, {"MemberId", member_id}, {"KickerMemberId", kicker_id}, {"OptData", enc}}; Json reply; return NpPost("/mp/matching2/kick_member", body, reply, error, 4000); } void RoomSet(const std::string& session_id, int member_id) { std::lock_guard lk(g_roommu); g_room_sid = session_id; g_room_mid = member_id; } void RoomClear() { RoomSet("", 0); } // ---- the event poller and the keepalive ------------------------------------------------------------- namespace { std::mutex g_pollmu; std::mutex g_pollflight; std::condition_variable g_pollcv; std::function g_handler; long long g_cursor = 0; // The server holds an empty poll open until an event for this player arrives or the wait ends // (WaitMs; droogie's long polling): invites arrive at once and the server is not asked four times a // second. Every answer from such a server carries WaitMaxMs; one without it answers at once, so // the poller goes back to the 250 ms pacing against it. One poll in flight per player: a poller // left over from a stop (still inside a held poll) ends instead of polling beside the new one. std::atomic g_pollgen{0}; int Jitter(int ms) { thread_local unsigned seed = static_cast( std::chrono::steady_clock::now().time_since_epoch().count() ^ std::hash{}(std::this_thread::get_id())); seed = seed * 1103515245u + 12345u; const int span = ms / 5; // +-20% return span > 0 ? ms - span + static_cast((seed >> 8) % static_cast(2 * span + 1)) : ms; } void PollerMain(unsigned gen) { const std::string me = OnlineId(); const auto running = [gen] { return g_pollrun.load() && g_pollgen.load() == gen; }; int wait_ms = 25000; // long polling from the first poll int backoff_ms = 0; while (running()) { Json body = {{"OnlineId", me}, {"Cursor", g_cursor}, {"Categories", Json::array({"matching2"})}}; if (wait_ms > 0) body["WaitMs"] = wait_ms; Json reply; std::string err; int pause_ms = 0; bool answered = false; std::int64_t took_ms = 0; { std::lock_guard flight(g_pollflight); if (!running()) break; const std::int64_t asked_at = NowMs(); answered = NpPost("/np/events/poll", body, reply, err, wait_ms > 0 ? wait_ms + 5000 : 4000); took_ms = NowMs() - asked_at; } if (!running()) break; if (answered) { backoff_ms = 0; const long long max_wait = IntOf(reply, "WaitMaxMs", 0); wait_ms = max_wait > 0 ? static_cast(std::min(25000, max_wait)) : 0; const long long next = IntOf(reply, "NextCursor", g_cursor); if (IntOf(reply, "HasEvent", 0) != 0) { std::function handler; { std::lock_guard lk(g_pollmu); handler = g_handler; } Log("np: event #%lld %s", IntOf(reply, "EventId", 0), StrOf(reply, "Name").c_str()); if (handler) { try { handler(reply); } catch (const std::exception& e) { Log("np: event handler: %s", e.what()); } } Json ack = {{"OnlineId", me}, {"Cursor", next}}; Json ack_reply; std::string ack_err; NpPost("/np/events/ack", ack, ack_reply, ack_err, 4000); g_cursor = next; continue; // drain without waiting while there are events } g_cursor = std::max(g_cursor, next); pause_ms = wait_ms > 0 ? static_cast(std::max(0, 250 - took_ms)) : 250; if (pause_ms == 0) continue; } else if (Refused()) { pause_ms = 60000; // nothing arrives until this PC signs in again } else { // The server is away (network error, timeout, 5xx, 429): 1, 2, 4 ... 30 s. backoff_ms = backoff_ms ? std::min(backoff_ms * 2, 30000) : 1000; pause_ms = Jitter(backoff_ms); } std::unique_lock lk(g_pollmu); g_pollcv.wait_for(lk, std::chrono::milliseconds(pause_ms), [&] { return !running(); }); } } std::atomic g_keeprun{false}; std::atomic g_keepgen{0}; std::mutex g_keepmu; std::condition_variable g_keepcv; // Our new reflexive address to the server (a relay port that changed). void ServerSignalingUpdate() { Json body = Json::object(); body["OnlineId"] = OnlineId(); { std::lock_guard lk(g_mapped.mu); body["MappedAddr"] = g_mapped.addr; body["MappedPort"] = g_mapped.port; } { std::lock_guard lk(g_roommu); body["SessionId"] = g_room_sid; body["MemberId"] = g_room_mid; } Json reply; std::string err; NpPost("/mp/matching2/signaling_update", body, reply, err, 4000); } // The STUN binding every 15 s while the context runs: it keeps our NAT's mapping to the server // open (routers forget an idle UDP mapping in 30 s to a few minutes) and the relay port alive. void KeepaliveMain(unsigned gen) { const auto running = [gen] { return g_keeprun.load() && g_keepgen.load() == gen; }; std::string last; { std::lock_guard lk(g_mapped.mu); last = g_mapped.addr + ":" + std::to_string(g_mapped.port); } while (running()) { { std::unique_lock lk(g_keepmu); g_keepcv.wait_for(lk, std::chrono::seconds(15), [&] { return !running(); }); } if (!running()) break; MappedRefresh(true); std::string now; { std::lock_guard lk(g_mapped.mu); now = g_mapped.addr + ":" + std::to_string(g_mapped.port); } if (now != last && now != ":0") { Log("stun: our address is now %s (was %s); telling the server", BbNet::MaskIpText(now).c_str(), BbNet::MaskIpText(last).c_str()); ServerSignalingUpdate(); last = now; } } } } // namespace void PollerStart(std::function handler) { std::lock_guard lk(g_pollmu); g_handler = std::move(handler); if (g_pollrun.exchange(true)) return; g_cursor = 0; std::thread(PollerMain, g_pollgen.fetch_add(1) + 1).detach(); if (!g_keeprun.exchange(true)) std::thread(KeepaliveMain, g_keepgen.fetch_add(1) + 1).detach(); } void PollerStop() { { std::lock_guard lk(g_keepmu); g_keeprun.store(false); g_keepcv.notify_all(); } std::lock_guard lk(g_pollmu); g_pollrun.store(false); g_pollcv.notify_all(); std::lock_guard client(g_poll_client_mu); if (g_poll_client) g_poll_client->stop(); } // ---- game memory: 1.09's addresses (Binary Ninja, image base 0x400000) ------------------------------- namespace { constexpr std::uint64_t kImageBase = 0x400000; constexpr std::uint64_t kCharaStep = 0x2043ee0; // the login's character step (1.09's check) constexpr std::uint64_t kSignalingGate = 0x586cc09; // u8: the NpMatching2 callbacks' gate constexpr std::uint64_t kFrpgNetMan = 0x593b120; // FrpgNetMan* (the singleton's slot) constexpr std::uint32_t kAreaOff = 0xa7c, kLevelOff = 0xaa8, kPosOff = 0xaac; const std::uint8_t kCharaStepPrologue[] = {0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x40}; std::uint8_t* g_image = nullptr; template bool ReadGuest(std::uint64_t at, T* out) { return at > 0x10000 && BbEngine::host_read_safe(reinterpret_cast(at), out, sizeof(T)); } } // namespace void SetImage(std::uint8_t* image, std::uint64_t size) { g_image = nullptr; if (!image || size < kFrpgNetMan + 8 - kImageBase) return; if (std::memcmp(image + (kCharaStep - kImageBase), kCharaStepPrologue, sizeof(kCharaStepPrologue)) != 0) { return; // not 1.09: no game memory is touched } g_image = image; } // The game's matching callbacks queue events only while this byte is 1; on PSN it was set for it. void SetSignalingGate() { if (!g_image) return; auto* gate = g_image + (kSignalingGate - kImageBase); if (*gate != 1) { *gate = 1; Log("np: signaling gate set"); } } // The host's area, level and position for the invite the server relays to the summoned player. void HostExtra(Json& extra) { if (!g_image) return; std::uint64_t man = 0; std::uint32_t area = 0, level = 0; float pos[3] = {}; if (!ReadGuest(reinterpret_cast(g_image + (kFrpgNetMan - kImageBase)), &man) || man <= 0x10000 || !ReadGuest(man + kAreaOff, &area) || !ReadGuest(man + kLevelOff, &level) || !BbEngine::host_read_safe(reinterpret_cast(man + kPosOff), pos, sizeof(pos))) { return; } extra["HostArea"] = area; extra["HostLevel"] = level; extra["HostPos"] = Json::array({pos[0], pos[1], pos[2]}); extra["MemberTag"] = 1; } // ---- SceNpId: the online id in its first 16 bytes -------------------------------------------------- void FillNpId(void* out, const char* online) { if (!out) return; std::memset(out, 0, 36); if (!online || !online[0]) online = "Player"; std::snprintf(static_cast(out), 17, "%s", online); } std::string NpIdText(const void* npid) { if (!npid) return ""; char buf[17]{}; std::memcpy(buf, npid, 16); return buf; } void Register(Core::Loader::SymbolsResolver* sym) { RegisterNet(sym); RegisterMatching2(sym); RegisterNp(sym); } } // namespace BbThd // The sceHttp hook (vendor/shadps4/core/libraries/network/http.cpp). extern "C" bool bbthd_enabled() { return BbThd::Enabled(); } extern "C" void bbthd_add_game_headers(std::vector>* headers) { if (headers) BbThd::AddGameHeaders(*headers); } extern "C" void bbthd_game_reply(int status, const std::string* body) { if (status != 401 || !body || BbRoom::Active()) return; const BbThd::Json reply = BbThd::Json::parse(*body, nullptr, false); if (!reply.is_object() || !reply.contains("ResKind")) return; const std::string why = BbThd::StrOf(reply, "Message"); BbThd::MarkRefused(why.empty() ? "HTTP 401" : why); }