From 7da8f6194d3993535fa291f7fe211e20b3c4c37a Mon Sep 17 00:00:00 2001 From: dann1k Date: Mon, 19 Oct 2026 21:02:19 +0200 Subject: [PATCH] PS5: Patches for fan translations and rebuilt discs, found by the game's own serial (AI-assisted) "Get patches and cheats" asks each source for the disc's own _. A fan translation changes the executable, so its CRC differs and nothing is found; a rebuilt disc that boots NORG_RUS.00 instead of SLUS_205.00 has no real serial at all. When nothing comes for the disc's own file, the downloader now tries the disc's serial, then the serials the executable names its memory card folder after ("BASLUS-20500"). Each source's file list (GitHub's tree API, once a run) gives the files of that serial; when there is exactly one, it is kept under the disc's own name (__.pnach), so PCSX2 loads it, with every group renamed "(from _)" and off. The first serial anything is found for wins; two files for one serial (two releases) are left alone. fe_ps5.cpp: the online patches' client takes up to 8 MB from api.github.com (PCSX2's list is 1.4 MB). Tested on a PC (ps5/frontend/host/test-patchdl.sh, all passed, with the real executables of two discs and the live file lists); not built for or tried on a console. Co-Authored-By: Claude Opus 5.5 --- AI-PR-NOTICE.md | 1 + ps5/frontend/fe_patchdl.cpp | 211 +++++++++++++++++++++++++++-- ps5/frontend/fe_patchdl.h | 25 ++++ ps5/frontend/fe_ps5.cpp | 6 +- ps5/frontend/host/patchdl_test.cpp | 98 ++++++++++++++ 5 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 AI-PR-NOTICE.md diff --git a/AI-PR-NOTICE.md b/AI-PR-NOTICE.md new file mode 100644 index 00000000..4f41ee03 --- /dev/null +++ b/AI-PR-NOTICE.md @@ -0,0 +1 @@ +This issue or PR was made via an AI agent and likely has not been reviewed by a human at all, your time may be entirely wasted. diff --git a/ps5/frontend/fe_patchdl.cpp b/ps5/frontend/fe_patchdl.cpp index 641d93e2..d85a5156 100644 --- a/ps5/frontend/fe_patchdl.cpp +++ b/ps5/frontend/fe_patchdl.cpp @@ -5,6 +5,8 @@ #include "fe_patchdl.h" +#include +#include #include #include #include @@ -130,10 +132,12 @@ bool IsPatchLine(const std::string& line) const std::vector& OnlinePatchSources() { static const std::vector sources = { - {"PCSX2", "https://raw.githubusercontent.com/PCSX2/pcsx2_patches/main/patches/%s.pnach", false, "", ""}, + {"PCSX2", "https://raw.githubusercontent.com/PCSX2/pcsx2_patches/main/patches/%s.pnach", false, "", "", + "https://api.github.com/repos/PCSX2/pcsx2_patches/git/trees/main:patches"}, {"Gabominated", "https://raw.githubusercontent.com/Gabominated/PCSX2/main/PCSX2%20Patches/%s.pnach", false, "_gabominated", - "Gabominated"}, - {"cheats", "https://raw.githubusercontent.com/xs1l3n7x/pcsx2_cheats_collection/main/cheats/%s.pnach", true, "", ""}, + "Gabominated", "https://api.github.com/repos/Gabominated/PCSX2/git/trees/main:PCSX2%20Patches"}, + {"cheats", "https://raw.githubusercontent.com/xs1l3n7x/pcsx2_cheats_collection/main/cheats/%s.pnach", true, "", "", + "https://api.github.com/repos/xs1l3n7x/pcsx2_cheats_collection/git/trees/main:cheats"}, }; return sources; } @@ -225,6 +229,65 @@ std::string TagPnachGroups(const std::string& text, const std::string& tag, cons return out; } +std::vector ExecutableSerials(const std::vector& elf) +{ + // "B" + region (A America, E Europe, I Asia) + "SLUS-20500": the memory card folder's name, as the game writes it. + std::vector out; + const auto upper = [](uint8_t c) { return c >= 'A' && c <= 'Z'; }; + const auto digit = [](uint8_t c) { return c >= '0' && c <= '9'; }; + for (size_t i = 0; i + 12 <= elf.size(); i++) + { + const uint8_t* p = elf.data() + i; + if (p[0] != 'B' || (p[1] != 'A' && p[1] != 'E' && p[1] != 'I') || p[6] != '-') + continue; + bool ok = upper(p[2]) && upper(p[3]) && upper(p[4]) && upper(p[5]); + for (int d = 7; ok && d < 12; d++) + ok = digit(p[d]); + // what follows isn't a 6th digit ("BASLUS-205001" is no serial) + if (!ok || (i + 12 < elf.size() && digit(p[12]))) + continue; + const std::string serial(reinterpret_cast(p + 2), 10); + if (std::find(out.begin(), out.end(), serial) == out.end()) + out.push_back(serial); + } + return out; +} + +std::vector TreeFilesForSerial(const std::string& json, const std::string& serial) +{ + // No JSON parser here: each "path" key's string value (GitHub writes "path": "SLUS-20500_E169BAF8.pnach", a space after the + // colon), names that need no escaping. + std::vector out; + for (size_t at = json.find("\"path\""); at != std::string::npos; at = json.find("\"path\"", at + 6)) + { + size_t start = at + 6; + while (start < json.size() && (json[start] == ' ' || json[start] == '\t' || json[start] == '\r' || json[start] == '\n')) + start++; + if (start >= json.size() || json[start] != ':') + continue; + start++; + while (start < json.size() && (json[start] == ' ' || json[start] == '\t' || json[start] == '\r' || json[start] == '\n')) + start++; + if (start >= json.size() || json[start] != '"') + continue; + start++; + const size_t end = json.find('"', start); + if (end == std::string::npos) + break; + const std::string name = json.substr(start, end - start); + // "_<8 hex digits>.pnach" and nothing else + if (name.size() != serial.size() + 1 + 8 + 6 || name.compare(0, serial.size() + 1, serial + "_") != 0 || + name.compare(name.size() - 6, 6, ".pnach") != 0) + continue; + bool hex = true; + for (size_t k = serial.size() + 1; hex && k < serial.size() + 9; k++) + hex = std::isxdigit(static_cast(name[k])) != 0; + if (hex) + out.push_back(name); + } + return out; +} + int CountPnachGroups(const std::string& text, bool* unnamed) { int groups = 0; @@ -394,14 +457,146 @@ uint32_t OnlinePatches::DiscCrc(const std::string& image, std::string& error) OnlinePatchStatus OnlinePatches::Fetch(const GameInfo& game) { SetStatus(game.serial, OnlinePatchStatus::State::Working, "Reading the game's CRC"); - std::string error; - const uint32_t crc = DiscCrc(game.path, error); - if (crc == 0) + std::string name; + std::vector elf; + if (!ReadAchievementExecutable(game.path, name, elf) || elf.size() < 4) { - Log(game.serial + ": " + error); + Log(game.serial + ": the game's executable couldn't be read from the disc image"); return {OnlinePatchStatus::State::Failed, "Couldn't read the game's CRC"}; } - return FetchWithCrc(game.serial, crc); + return FetchWithExecutable(game.serial, ElfCrc(elf), elf); +} + +OnlinePatchStatus OnlinePatches::FetchWithExecutable(const std::string& serial, uint32_t crc, const std::vector& elf) +{ + const OnlinePatchStatus own = FetchWithCrc(serial, crc); + if (own.state != OnlinePatchStatus::State::Done || own.message.rfind("Nothing online", 0) != 0) + return own; + // The disc's own serial first (a translation that kept it: same game, another CRC), then the ones the executable names. + std::vector serials = {serial}; + for (const std::string& s : ExecutableSerials(elf)) + if (s != serial) + serials.push_back(s); + std::string names; + for (const std::string& s : serials) + names += (names.empty() ? "" : ", ") + s; + Log(serial + ": nothing for the disc's own CRC; looking by serial: " + names); + const OnlinePatchStatus found = FetchBySerial(serial, crc, serials); + return found.state == OnlinePatchStatus::State::Idle ? own : found; +} + +const std::string* OnlinePatches::SourceTree(const OnlinePatchSource& src, int& status) +{ + const auto it = m_trees.find(src.name); + if (it != m_trees.end()) + { + status = 200; + return &it->second; + } + std::string body; + status = m_platform.get_text(src.tree, body); + // a list that isn't one (an HTML page, GitHub's 403 once its 60 unauthenticated requests an hour are used) is not kept: + // the next game asks again + if (status != 200 || body.find("\"tree\"") == std::string::npos) + return nullptr; + return &(m_trees[src.name] = std::move(body)); +} + +OnlinePatchStatus OnlinePatches::FetchBySerial(const std::string& disc_serial, uint32_t crc, const std::vector& serials) +{ + char disc_key[48]; + std::snprintf(disc_key, sizeof(disc_key), "%s_%08X", disc_serial.c_str(), crc); + int found_patches = 0, found_cheats = 0, failed = 0; + std::string why, from; + // The first serial anything is found for wins: a later one is another release (Red Dead Revolver's rebuild names the PAL + // release's memory card folder too, but its code is the US one's). + for (const std::string& serial : serials) + { + if (found_patches || found_cheats) + break; + for (const OnlinePatchSource& src : OnlinePatchSources()) + { + SetStatus(disc_serial, OnlinePatchStatus::State::Working, std::string("Asking ") + src.name + " for " + serial); + int status = 0; + const std::string* tree = SourceTree(src, status); + if (!tree) + { + if (serial == serials.front()) + { + failed++; + why = std::string(src.name) + "'s file list: " + (status < 0 ? "no connection" : "HTTP " + std::to_string(status)); + Log(std::string(disc_key) + ": " + why); + } + continue; + } + const std::vector files = TreeFilesForSerial(*tree, serial); + if (files.size() != 1) + { + // none, or two releases of the game (which one the disc was made from isn't known): left alone + if (!files.empty()) + Log(std::string(disc_key) + ": " + src.name + ": " + std::to_string(files.size()) + " files for " + serial + + " (more than one release): none taken"); + continue; + } + const std::string key = files[0].substr(0, files[0].size() - 6); + std::string body; + const int got = m_platform.get_text(OnlinePatchUrl(src, key), body); + if (got != 200 || ClassifyPnach(body) != PnachKind::Patches) + { + Log(std::string(disc_key) + ": " + src.name + ": " + key + ": " + + (got == 200 ? "nothing to apply in it" : "HTTP " + std::to_string(got))); + continue; + } + // Under the disc's own name, so PCSX2 loads it for this game; every group named after where it came from, and off. + const std::string tag = src.tag[0] != '\0' ? std::string(src.tag) + ", from " + key : "from " + key; + std::string text = "// PS5SX2: " + std::string(src.name) + "'s " + files[0] + ", for the game's own serial (" + serial + + ") found in this disc's executable. Made for another executable: test each group before you keep it on.\n" + + TagPnachGroups(body, tag, tag); + if (text.back() != '\n') + text += "\n"; + const std::string dir = src.cheats ? m_cheats : m_patches; + MakeDir(dir); + const std::string path = dir + "/" + disc_key + src.suffix + "_" + serial + ".pnach"; + std::string current; + const bool exists = ReadWhole(path, current); + const auto mine = m_manifest.find(path); + const int groups = CountPnachGroups(text); + if (exists && current != text && (mine == m_manifest.end() || mine->second != Fingerprint(current))) + { + Log(std::string(disc_key) + ": " + src.name + ": " + path + " is there and wasn't written by this app: left as it is"); + (src.cheats ? found_cheats : found_patches) += groups; + continue; + } + if (!exists || current != text) + { + std::string werr; + if (!WriteWhole(path, text, werr)) + { + failed++; + why = werr; + Log(std::string(disc_key) + ": " + src.name + ": " + werr); + continue; + } + m_manifest[path] = Fingerprint(text); + SaveManifest(); + Log(std::string(disc_key) + ": " + src.name + ": " + key + " -> " + path + " (" + std::to_string(groups) + " group(s))"); + } + (src.cheats ? found_cheats : found_patches) += groups; + if (from.find(key) == std::string::npos) + from += (from.empty() ? "" : ", ") + key; + } + } + if (found_patches || found_cheats) + { + const std::string message = std::to_string(found_patches) + (found_patches == 1 ? " patch" : " patches") + ", " + + std::to_string(found_cheats) + (found_cheats == 1 ? " cheat" : " cheats") + " from " + from + + " (made for that release: test before use)"; + Log(std::string(disc_key) + ": " + message); + return {OnlinePatchStatus::State::Done, message}; + } + if (failed) + return {OnlinePatchStatus::State::Failed, why}; + return {}; // Idle: nothing by serial either (the caller keeps its own "Nothing online") } OnlinePatchStatus OnlinePatches::FetchWithCrc(const std::string& serial, uint32_t crc) diff --git a/ps5/frontend/fe_patchdl.h b/ps5/frontend/fe_patchdl.h index 5eb85ee0..0c0ae2ae 100644 --- a/ps5/frontend/fe_patchdl.h +++ b/ps5/frontend/fe_patchdl.h @@ -14,6 +14,18 @@ // wrote). The sheet then lists the groups under Patches and Cheats, each off until turned on. Needs proper testing on the // console. // +// 2.16 (AI-assisted): when no source has a file for the disc's own "_" (a fan translation or a rebuilt disc: +// its executable differs, so its CRC does, and some boot "NORG_RUS.00" instead of "SLUS_205.00", which gives no serial at +// all), the game's own serial is looked for inside its executable: a PS2 game names its memory card folder after it +// ("BASLUS-20500", "BESLES-52478"). For each one found, each patch source's file list (GitHub's tree API, one request a +// source) gives the files of that serial; when it has just one, that file is fetched and kept under the DISC's name +// ("__.pnach"), so PCSX2 loads it for this game. Every group gets "(from _)" in +// its name and stays off: a patch made for another executable may not fit this one (a translation that moved code would +// crash or misdraw), so turning one on is the player's own test. Two files for one serial (two releases) are left alone, +// and the first serial anything is found for wins. The disc's own serial is tried first (a translation that kept it). +// Asked only when the disc's own file wasn't found; each list once a run (GitHub allows 60 such requests an hour without +// an account). Not tried on a console yet: needs proper testing there. +// // Copyright (C) 2026 swordpdf // SPDX-License-Identifier: GPL-3.0-or-later @@ -70,6 +82,7 @@ struct OnlinePatchSource bool cheats; // into the cheats folder (else the patches folder) const char* suffix; // the local file's name after "_" ("" or "_gabominated") const char* tag; // groups renamed " ()" ("" leaves them) + const char* tree; // 2.16: GitHub's tree API for the source's folder (its file names), for the fallback by serial }; const std::vector& OnlinePatchSources(); // A source's URL for "_": its first "%s" replaced by `key`. vk-285-134b: not through snprintf, since the URL @@ -95,6 +108,12 @@ PnachKind ClassifyPnach(const std::string& text); std::string TagPnachGroups(const std::string& text, const std::string& tag, const std::string& unnamed); // How many groups ("[...]" lines) a pnach text has, and whether it has patch lines outside any group. int CountPnachGroups(const std::string& text, bool* unnamed = nullptr); +// 2.16 (AI-assisted): the serials a PS2 executable names its memory card folders after ("BASLUS-20500" -> "SLUS-20500"; +// region letter A, E or I, then the 4 letters, a dash and 5 digits), in the order found, each once. Most games have one; +// some have two regions' (Red Dead Revolver's Russian rebuild: SLUS-20500 and SLES-52478). +std::vector ExecutableSerials(const std::vector& elf); +// 2.16: the "_.pnach" names in a GitHub tree API answer ({"tree":[{"path":"..."}]}) for one serial, in order. +std::vector TreeFilesForSerial(const std::string& json, const std::string& serial); class OnlinePatches { @@ -117,8 +136,13 @@ class OnlinePatches static uint32_t DiscCrc(const std::string& image, std::string& error); // Fetch with a CRC already known (the test's games have no executable). OnlinePatchStatus FetchWithCrc(const std::string& serial, uint32_t crc); + // 2.16: FetchWithCrc, then, when it found nothing, the fallback by the serials in the executable (see above). + OnlinePatchStatus FetchWithExecutable(const std::string& serial, uint32_t crc, const std::vector& elf); private: + OnlinePatchStatus FetchBySerial(const std::string& disc_serial, uint32_t crc, const std::vector& serials); + // One source's file list, asked once a run (the same lists serve every game asked after it). + const std::string* SourceTree(const OnlinePatchSource& src, int& status); void Run(); void SetStatus(const std::string& serial, OnlinePatchStatus::State state, const std::string& message); void Log(const std::string& line) const; @@ -134,5 +158,6 @@ class OnlinePatches bool m_stop = false, m_running = false; std::deque m_queue; std::map m_status; // by serial + std::map m_trees; // 2.16: a source's tree API answer, by source name (worker thread only) }; } // namespace fe diff --git a/ps5/frontend/fe_ps5.cpp b/ps5/frontend/fe_ps5.cpp index b49c8751..b7dbcc63 100644 --- a/ps5/frontend/fe_ps5.cpp +++ b/ps5/frontend/fe_ps5.cpp @@ -2147,8 +2147,10 @@ std::string orbis_frontend_run(const OrbisFrontendPaths& paths, const char* buil OnlinePatchPlatform op; op.get_text = [](const std::string& url, std::string& body) { body.clear(); - return g_patchhttp.Get(url, false, 0, 0, [&body](const void* d, size_t n) { - if (body.size() + n > (2u << 20)) + // 2.16: a source's file list (GitHub's tree API, 1.4 MB for PCSX2's in October 2026, and growing) may be bigger than a pnach + const size_t cap = url.rfind("https://api.github.com/", 0) == 0 ? (8u << 20) : (2u << 20); + return g_patchhttp.Get(url, false, 0, 0, [&body, cap](const void* d, size_t n) { + if (body.size() + n > cap) return false; body.append(static_cast(d), n); return true; diff --git a/ps5/frontend/host/patchdl_test.cpp b/ps5/frontend/host/patchdl_test.cpp index d58789ab..fe52e0cf 100644 --- a/ps5/frontend/host/patchdl_test.cpp +++ b/ps5/frontend/host/patchdl_test.cpp @@ -189,6 +189,104 @@ int main(int argc, char** argv) "a file with its patches commented out is nothing online, not a failure: " + s.message); } + // 2.16 (AI-assisted): the serials an executable names its memory card folders after. + { + const std::string s = std::string("\x7f" "ELF....BASLUS-20500\0..BESLES-52478\0BASLUS-20500\0BASLUS-205001\0BXSLUS-11111\0", 79); + const std::vector elf(s.begin(), s.end()); + const std::vector got = fe::ExecutableSerials(elf); + Check(got.size() == 2 && got[0] == "SLUS-20500" && got[1] == "SLES-52478", + "ExecutableSerials finds BASLUS-20500 and BESLES-52478 once each, in order, and nothing else"); + const std::string tree = R"({"sha":"x","tree":[{"path":"SLUS-20500_E169BAF8.pnach","mode":"100644"},)" + R"({"path":"SLUS-20500_E169BAF8.txt"},{"path":"SLUS-205001_00000000.pnach"},{"path":"SLES-52478_E2E67E23.pnach"}]})"; + const std::vector files = fe::TreeFilesForSerial(tree, "SLUS-20500"); + Check(files.size() == 1 && files[0] == "SLUS-20500_E169BAF8.pnach", "TreeFilesForSerial takes only _.pnach"); + // GitHub's own layout: a space after each colon, one key a line + const std::string github = "{\n \"tree\": [\n {\n \"path\": \"SLUS-21423_7EA439F5.pnach\",\n \"mode\": \"100644\"\n }\n ]\n}\n"; + const std::vector gh = fe::TreeFilesForSerial(github, "SLUS-21423"); + Check(gh.size() == 1 && gh[0] == "SLUS-21423_7EA439F5.pnach", "and reads GitHub's answer as GitHub writes it"); + } + // 2.16: nothing for the disc's own CRC: the fallback by serial. + { + const std::string pcsx2_tree = "https://api.github.com/repos/PCSX2/pcsx2_patches/git/trees/main:patches"; + const std::string gab_tree = "https://api.github.com/repos/Gabominated/PCSX2/git/trees/main:PCSX2%20Patches"; + const std::string cheats_tree = "https://api.github.com/repos/xs1l3n7x/pcsx2_cheats_collection/git/trees/main:cheats"; + server[pcsx2_tree] = {200, R"({"tree":[{"path":"SLUS-21423_7EA439F5.pnach"},{"path":"SLUS-20500_E169BAF8.pnach"},)" + R"({"path":"SLES-52478_E2E67E23.pnach"},{"path":"SLUS-20946_AAAAAAAA.pnach"},{"path":"SLUS-20946_BBBBBBBB.pnach"}]})"}; + server[gab_tree] = {200, R"({"tree":[{"path":"SLES-52478_E2E67E23.pnach"}]})"}; + server[cheats_tree] = {200, R"({"tree":[]})"}; + const std::string base = "https://raw.githubusercontent.com/PCSX2/pcsx2_patches/main/patches/"; + server[base + "SLUS-21423_7EA439F5.pnach"] = {200, "gametitle=LCS\n[Widescreen 16:9]\npatch=1,EE,002918d0,word,468010a0\n"}; + server[base + "SLUS-20500_E169BAF8.pnach"] = {200, "gametitle=RDR\n[Force 60FPS]\npatch=1,EE,201018AC,word,24030001\n"}; + server[base + "SLES-52478_E2E67E23.pnach"] = {200, "gametitle=RDR PAL\n[50/60 FPS]\npatch=1,EE,001018AC,word,24030001\n"}; + + // A translation that kept the serial (GTA LCS, SLUS-21423, CRC 3AF039F8 against PCSX2's 7EA439F5). + const std::string lcs_elf = "\x7f" "ELF...BASLUS-21423"; + { + asked.clear(); + fe::OnlinePatches op(platform, patches, cheats, manifest); + const fe::OnlinePatchStatus s = op.FetchWithExecutable("SLUS-21423", 0x3AF039F8, std::vector(lcs_elf.begin(), lcs_elf.end())); + const std::string file = Read(patches + "/SLUS-21423_3AF039F8_SLUS-21423.pnach"); + Check(s.state == fe::OnlinePatchStatus::State::Done && s.message.find("from SLUS-21423_7EA439F5") != std::string::npos, + "same serial, other CRC: the serial's one file is found: " + s.message); + Check(file.find("[Widescreen 16:9 (from SLUS-21423_7EA439F5)]") != std::string::npos && file.find("// PS5SX2: ") == 0, + "it is kept under the disc's CRC, its group named after where it came from"); + int trees = 0; + for (const std::string& u : asked) + trees += u.find("api.github.com") != std::string::npos; + Check(trees == 3, "each source's list asked once (" + std::to_string(trees) + ")"); + } + // A rebuild with no serial of its own (Red Dead Revolver NORG: "NORG-RUS00"), its executable naming SLUS-20500 first, + // then SLES-52478: only the first serial found is taken (not the PAL release's too). + const std::string rdr = std::string("\x7f" "ELF..BASLUS-20500\0BESLES-52478", 30); + { + fe::OnlinePatches op(platform, patches, cheats, manifest); + const fe::OnlinePatchStatus s = op.FetchWithExecutable("NORG-RUS00", 0x87844524, std::vector(rdr.begin(), rdr.end())); + Check(s.state == fe::OnlinePatchStatus::State::Done && Exists(patches + "/NORG-RUS00_87844524_SLUS-20500.pnach") && + !Exists(patches + "/NORG-RUS00_87844524_SLES-52478.pnach") && + !Exists(patches + "/NORG-RUS00_87844524_gabominated_SLES-52478.pnach"), + "no serial of its own: the executable's first serial wins: " + s.message); + const fe::OnlinePatchStatus again = op.FetchWithExecutable("NORG-RUS00", 0x87844524, std::vector(rdr.begin(), rdr.end())); + Check(again.state == fe::OnlinePatchStatus::State::Done, "asking again keeps it (the file is this app's)"); + } + // Two files for one serial (two releases): which one the disc was made from isn't known, so none is taken. + { + const std::string two = "\x7f" "ELF...BASLUS-20946"; + fe::OnlinePatches op(platform, patches, cheats, manifest); + const fe::OnlinePatchStatus s = op.FetchWithExecutable("NORG-00000", 0x12121212, std::vector(two.begin(), two.end())); + Check(s.message == "Nothing online for this game" && !Exists(patches + "/NORG-00000_12121212_SLUS-20946.pnach"), + "two releases of the serial: none taken: " + s.message); + } + // The lists can't be had (GitHub's hourly limit): said so, nothing written, and the next game asks again. + { + const auto saved = server[pcsx2_tree]; + server[pcsx2_tree] = {403, R"({"message":"API rate limit exceeded"})"}; + server[gab_tree] = {403, R"({"message":"API rate limit exceeded"})"}; + server[cheats_tree] = {403, R"({"message":"API rate limit exceeded"})"}; + fe::OnlinePatches op(platform, patches, cheats, manifest); + const std::string other = "\x7f" "ELF...BASLUS-21423"; + const fe::OnlinePatchStatus s = op.FetchWithExecutable("SLUS-21423", 0x01020304, std::vector(other.begin(), other.end())); + Check(s.state == fe::OnlinePatchStatus::State::Failed && s.message.find("HTTP 403") != std::string::npos && + !Exists(patches + "/SLUS-21423_01020304_SLUS-21423.pnach"), + "no file list (HTTP 403): a failure that says why, no file: " + s.message); + server[pcsx2_tree] = saved; + server[gab_tree] = {200, R"({"tree":[]})"}; + server[cheats_tree] = {200, R"({"tree":[]})"}; + const fe::OnlinePatchStatus again = op.FetchWithExecutable("SLUS-21423", 0x01020304, std::vector(other.begin(), other.end())); + Check(again.state == fe::OnlinePatchStatus::State::Done && Exists(patches + "/SLUS-21423_01020304_SLUS-21423.pnach"), + "a list refused is not kept: asked again, it is found: " + again.message); + } + // The disc's own file is there: no fallback at all. + { + asked.clear(); + fe::OnlinePatches op(platform, patches, cheats, manifest); + op.FetchWithExecutable("SLUS-20265", 0x79646C72, std::vector(lcs_elf.begin(), lcs_elf.end())); + bool tree_asked = false; + for (const std::string& u : asked) + tree_asked |= u.find("api.github.com") != std::string::npos; + Check(!tree_asked && !Exists(patches + "/SLUS-20265_79646C72_SLUS-21423.pnach"), "a disc with its own file found asks no list"); + } + } + // The sheet: the game's Cheats rows, on and off, and EmuCore/EnableCheats with them. { fe::OptionsPaths paths;