diff --git a/Engine/Vfs/builtin_packs.cpp b/Engine/Vfs/builtin_packs.cpp index 1f44720..52af007 100644 --- a/Engine/Vfs/builtin_packs.cpp +++ b/Engine/Vfs/builtin_packs.cpp @@ -1,5 +1,6 @@ #include "builtin_packs.h" #include "builtin_pack_format.h" +#include "Engine/Core/Platform/path_text.h" #include #include #include @@ -22,13 +23,14 @@ struct Embedded { const wchar_t* resource; const char* name; }; -constexpr Embedded embedded[]{{L"BUILTIN_PACK_FPV_DRONE", "fpv_drone"}, - {L"BUILTIN_PACK_BOARD_BREAK", "board_break"}, - {L"BUILTIN_PACK_ROAD_RASH", "road_rash"}}; -// The mods those replace, by folder name in small letters: as Thunderstore installs them -// (Team-Name), and the bare names a mod put in by hand would have. -constexpr std::string_view superseded[]{"fourth_studio-board_break_pack", "fourth_studio-board_break", - "fourth_studio-road_rash", "board_break_pack", "road_rash"}; +constexpr Embedded embedded[]{{L"BUILTIN_PACK_FPV_DRONE", "fpv_drone"}}; +// Retired mods (builtin_packs.h), in small letters. +constexpr std::string_view retired[]{"fourth_studio-board_break"}; +std::string lower_text(std::string_view text) { + std::string out(text); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return out; +} constexpr std::uint64_t max_pack_bytes = builtin_pack::max_bytes; constexpr std::string_view version_file = ".builtin-version"; @@ -218,10 +220,39 @@ bool unpack_builtin_pack(std::span bytes, const fs::path& t } } -bool superseded_by_builtin(std::string_view folder) noexcept { - std::string name(folder); - std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - return std::find(std::begin(superseded), std::end(superseded), name) != std::end(superseded); +std::vector official_packs(const fs::path& mods_root) noexcept { + std::vector packs; + try { + std::error_code error; + for (fs::directory_iterator at(mods_root / std::string(official_folder), error), end; !error && at != end; at.increment(error)) { + if (!at->is_directory(error) || !fs::is_regular_file(at->path() / std::string(official_stamp), error)) continue; + const auto wide = at->path().filename().wstring(); + if (wide.empty() || wide.front() == L'.' || wide.find(L".new") != std::wstring::npos) continue; + std::string name; + for (const auto c : wide) name.push_back(c < 0x80 ? static_cast(c) : '_'); + packs.push_back({std::move(name), at->path()}); + } + std::sort(packs.begin(), packs.end(), [](const BuiltinPack& a, const BuiltinPack& b) { return a.name < b.name; }); + } catch (...) {} + return packs; +} + +bool retired_mod(std::string_view folder) noexcept { + try { + return std::find(std::begin(retired), std::end(retired), lower_text(folder)) != std::end(retired); + } catch (...) { + return false; + } +} + +bool kept_by_reskate(const fs::path& mods_root, std::string_view folder) noexcept { + try { + if (folder.empty() || retired_mod(folder)) return !folder.empty(); + std::error_code error; + return fs::is_regular_file(mods_root / std::string(official_folder) / std::string(folder) / std::string(official_stamp), error); + } catch (...) { + return false; + } } std::vector unpack_builtin_packs(const fs::path& mods_root, std::vector& notes) noexcept { @@ -229,8 +260,8 @@ std::vector unpack_builtin_packs(const fs::path& mods_root, std::ve try { Source source; open_source(source); - if (!source.module) return packs; for (const auto& pack : embedded) { + if (!source.module) break; const auto bytes = resource_bytes(source.module, pack.resource); if (bytes.empty()) continue; const auto directory = mods_root / std::string(builtin_folder) / pack.name; @@ -258,6 +289,19 @@ std::vector unpack_builtin_packs(const fs::path& mods_root, std::ve } packs.push_back({std::string("reskate-builtin-") + pack.name, directory}); } + // What an older ReSkate carried and this one does not (it became an official mod, or was + // dropped) goes: the Board Break deck list reads every folder in here. + std::error_code error; + const auto folder = mods_root / std::string(builtin_folder); + std::vector stale; + for (fs::directory_iterator at(folder, error), end; !error && at != end; at.increment(error)) + if (std::none_of(packs.begin(), packs.end(), [&](const BuiltinPack& pack) { return pack.directory == at->path(); })) + stale.push_back(at->path()); + for (const auto& path : stale) { + fs::remove_all(path, error); + if (!error) notes.push_back("Built-in content " + path_utf8(path.filename()) + " is no longer carried; removed"); + } + if (packs.empty()) fs::remove(folder, error); } catch (const std::exception& failure) { notes.push_back(std::string("Built-in content could not be prepared: ") + failure.what()); } diff --git a/Engine/Vfs/builtin_packs.h b/Engine/Vfs/builtin_packs.h index 463818f..17eb965 100644 --- a/Engine/Vfs/builtin_packs.h +++ b/Engine/Vfs/builtin_packs.h @@ -5,9 +5,8 @@ #include // Game content ReSkate carries itself: packs of game assets built by ReSkate Studio, zipped into -// ReSkate.dll: the FPV drone's model (tools/models/build_drone_pack.py), and Fourth Studio's Board -// Break decks and sounds and Road Rash wounds, which were Thunderstore mods. Nobody installs or -// sees them. Before a merge each is unpacked under Mods/.reskate-builtin/ (a dot folder, +// ReSkate.dll (the FPV drone's model, tools/models/build_drone_pack.py). Nobody installs or sees +// them. Official mods (below) are the other content ReSkate keeps for every player. Before a merge each is unpacked under Mods/.reskate-builtin/ (a dot folder, // which no mod list shows) when the one embedded differs from what is there, and the catalogue // merges it after every installed mod, always on (mod_catalog.cpp). namespace dingosdk::mods { @@ -24,9 +23,20 @@ std::vector unpack_builtin_packs(const std::filesystem::path& mods_ // One pack's bytes as ReSkate.dll carries them (builtin_pack_format.h, or a plain zip) unpacked // into `target`; false with `issue` saying why. For tests and tools: unpack_builtin_packs uses it. bool unpack_builtin_pack(std::span bytes, const std::filesystem::path& target, std::string& issue) noexcept; -// Whether an installed mod (its folder's name) is one a built-in pack replaces: Fourth Studio's -// Board Break pack (and its deprecated first one) and Road Rash, as Thunderstore installs them or -// put in by hand. The launcher moves such a mod to the Recycle Bin, the merge leaves it out -// should it still be there, and the launcher's Thunderstore browser neither lists nor installs it. -bool superseded_by_builtin(std::string_view folder) noexcept; + +// Official mods (Launcher/official_mods.h): Thunderstore packages the ReSkate backend names, which +// the launcher keeps installed at their latest version for every player in +// Mods/.reskate-official/, each with a stamp file holding its version (written +// last, so a folder without one is not a whole mod). Always on: merged after every installed mod, +// like the packs above. Not in mods.json, and no mod list shows them. +inline constexpr std::string_view official_folder = ".reskate-official"; +inline constexpr std::string_view official_stamp = ".official-version"; +// The official mods in place under `mods_root`, by folder name. Never throws. +std::vector official_packs(const std::filesystem::path& mods_root) noexcept; +// Mods that came before an official one and are retired with it (Fourth Studio's deprecated first +// Board Break), by folder name; the launcher takes them away and nothing loads them. +bool retired_mod(std::string_view folder) noexcept; +// Whether the installed mod `folder` (Mods/) is left out because ReSkate keeps it itself: +// an official mod in place under `mods_root`, or a retired one. Never throws. +bool kept_by_reskate(const std::filesystem::path& mods_root, std::string_view folder) noexcept; } // namespace dingosdk::mods diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index 9447713..313b107 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -142,11 +142,12 @@ std::vector deck_mod_folders() { const auto mods = engine_data_root() / L"Mods"; std::error_code error; for (fs::directory_iterator at(mods, error), end; !error && at != end; at.increment(error)) - if (at->is_directory(error) && !superseded_by_builtin(path_utf8(at->path().filename()))) found.push_back(at->path()); + if (at->is_directory(error) && !kept_by_reskate(mods, path_utf8(at->path().filename()))) found.push_back(at->path()); error.clear(); for (fs::directory_iterator at(mods / std::string(builtin_folder), error), end; !error && at != end; at.increment(error)) if (at->is_directory(error) && at->path().filename().wstring().find(L".unpacking-") == std::wstring::npos) found.push_back(at->path()); + for (auto& official : official_packs(mods)) found.push_back(std::move(official.directory)); return found; } // Every deck in the Mods folder with the folder of the mod that brings it, in the menus' order, @@ -336,16 +337,19 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver try { for (const auto& name : list.missing) result.notes.push_back("mods.json names a folder that is not present: " + name); - // A mod a built-in pack replaces (builtin_packs.h) is left out, should the launcher not have - // taken it away yet: its content would load twice. + // A mod ReSkate keeps itself (an official mod, or one retired with it: builtin_packs.h) is + // left out, should the launcher not have taken it away yet: its content would load twice. std::erase_if(list.entries, [&](const ModEntry& entry) { - if (!superseded_by_builtin(entry.mod.name)) return false; - result.notes.push_back(entry.mod.name + " is built into ReSkate now; the installed copy is left out"); + if (!kept_by_reskate(data_root / mods_folder, entry.mod.name)) return false; + result.notes.push_back(entry.mod.name + " is an official mod ReSkate keeps itself; the copy in the Mods folder is left out"); return true; }); - // After every installed mod, so none of theirs is ever shadowed by it; always on. + // After every installed mod, so none of theirs is ever shadowed by it; always on. Official + // mods (builtin_packs.h) the same way. for (const auto& pack : builtin) list.entries.push_back({describe_mod(pack.directory, pack.name, result.notes), true}); + for (const auto& pack : official_packs(data_root / mods_folder)) + list.entries.push_back({describe_mod(pack.directory, pack.name, result.notes), true}); // Board Break: a deck pack holds several decks in one mod folder (mod_catalog.h). Each deck // inside becomes a mod of its own for the run, listed after the pack as #, so // that a pack's decks are chosen, set aside and switched like decks in folders of their own. diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index 3f75ede..2bbfea9 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -1,5 +1,6 @@ // Launcher mod manager: install from .zip and folders, the Mods list and mods.json. #include "Launcher/mod_manager.h" +#include "Launcher/official_mods.h" #include "Engine/Vfs/builtin_pack_format.h" #include "Engine/Vfs/builtin_packs.h" @@ -398,32 +399,79 @@ int main() { } } - // Mods ReSkate now carries itself (Board Break, Road Rash) go, and mods.json forgets them; - // every other mod stays as it was. + // Official mods (Launcher/official_mods.h): the backend's list, read strictly. { - check(mods::superseded_by_builtin("Fourth_Studio-Road_Rash") && mods::superseded_by_builtin("fourth_studio-board_break_pack") && - mods::superseded_by_builtin("Fourth_Studio-Board_Break") && mods::superseded_by_builtin("Road_Rash") && - !mods::superseded_by_builtin("Fourth_Studio-Gothic_Tights") && !mods::superseded_by_builtin("Someone-Road_Rash_Remix"), - "Only the mods built in now are taken for them"); - const auto store = root / L"retire" / L"Mods"; - fs::create_directories(store / L"Fourth_Studio-Board_Break_Pack" / L"decks"); + const auto parsed = official_mods::parse_list(R"({"community":"reskate","mods":[ + {"full_name":"Fourth_Studio-Board_Break_Pack","owner":"Fourth_Studio","name":"Board_Break_Pack","title":"Board Break Pack", + "description":"Decks","icon":"https://gcdn.thunderstore.io/live/icon.png","version":"2.3.0", + "download_url":"https://thunderstore.io/package/download/Fourth_Studio/Board_Break_Pack/2.3.0/","file_size":1}, + {"full_name":"Fourth_Studio-Road_Rash","owner":"Fourth_Studio","name":"Road_Rash"}, + {"full_name":"Evil-Mod","version":"1.0.0","download_url":"https://example.com/evil.zip"}, + {"full_name":"../escape"}, {"full_name":"Fourth_Studio-Road_Rash"}, 7]})"); + check(parsed.size() == 3 && parsed[0].full_name == "Fourth_Studio-Board_Break_Pack" && parsed[0].version == "2.3.0" && + parsed[0].title == "Board Break Pack" && parsed[0].icon.starts_with("https://gcdn.thunderstore.io/") && + parsed[1].full_name == "Fourth_Studio-Road_Rash" && parsed[1].version.empty() && parsed[1].title == "Road Rash" && + parsed[2].full_name == "Evil-Mod" && parsed[2].version.empty() && parsed[2].download_url.empty(), + "The official list keeps what it should: no odd names, no duplicates, no downloads from elsewhere"); + check(official_mods::parse_list("go away").empty() && official_mods::parse_list(R"({"lobbies":3})").empty(), + "Anything else is no list"); + const auto fallback = official_mods::fallback_list(); + check(fallback.size() == 2 && fallback[0].full_name == "Fourth_Studio-Board_Break_Pack" && fallback[1].full_name == "Fourth_Studio-Road_Rash", + "ReSkate's own list is Board Break and Road Rash"); + } + // A copy the player installed is moved in rather than downloaded again; the deprecated first + // Board Break is retired; mods.json forgets both; every other mod stays as it was. + { + const auto store = root / L"official" / L"Mods"; fs::create_directories(store / L"Fourth_Studio-Road_Rash"); + fs::create_directories(store / L"Fourth_Studio-Board_Break"); fs::create_directories(store / L"Someone-Other_Mod"); std::ofstream(store / L"Fourth_Studio-Road_Rash" / L"layout.toc") << "toc"; + std::ofstream(store / L"Fourth_Studio-Road_Rash" / L"manifest.json") << R"({"name":"Road_Rash","version_number":"1.0.0"})"; std::ofstream(store / L"Someone-Other_Mod" / L"layout.toc") << "toc"; - std::ofstream(store / L"mods.json") - << R"({"schema":1,"mods":[{"name":"Fourth_Studio-Road_Rash","enabled":true},{"name":"Someone-Other_Mod","enabled":false}]})"; - auto retired = launcher_mods::retire_superseded(store); - std::sort(retired.begin(), retired.end()); - check(retired == std::vector{"Fourth_Studio-Board_Break_Pack", "Fourth_Studio-Road_Rash"} && - !fs::exists(store / L"Fourth_Studio-Road_Rash") && !fs::exists(store / L"Fourth_Studio-Board_Break_Pack") && + std::ofstream(store / L"mods.json") << R"({"schema":1,"mods":[{"name":"Fourth_Studio-Road_Rash","enabled":true},)" + R"({"name":"Fourth_Studio-Board_Break","enabled":true},{"name":"Someone-Other_Mod","enabled":false}]})"; + const auto official = official_mods::fallback_list(); + const auto done = official_mods::adopt_and_retire(store, official); + const auto adopted = store / L".reskate-official" / L"Fourth_Studio-Road_Rash"; + check(done.size() == 2 && read(adopted / L"layout.toc") == "toc" && read(adopted / L".official-version").starts_with("1.0.0") && + !fs::exists(store / L"Fourth_Studio-Road_Rash") && !fs::exists(store / L"Fourth_Studio-Board_Break") && fs::exists(store / L"Someone-Other_Mod" / L"layout.toc"), - "The mods built in now are taken away, the others left"); + "An installed official mod moves in, a retired one goes, the others stay"); const auto remaining = mods::scan_mods(store.parent_path()); check(remaining.issue.empty() && remaining.missing.empty() && remaining.entries.size() == 1 && remaining.entries[0].mod.name == "Someone-Other_Mod" && !remaining.entries[0].enabled, "mods.json forgets them and keeps the rest as it was"); - check(launcher_mods::retire_superseded(store).empty(), "Nothing is taken away twice"); + const auto packs = mods::official_packs(store); + check(packs.size() == 1 && packs[0].name == "Fourth_Studio-Road_Rash" && mods::kept_by_reskate(store, "Fourth_Studio-Road_Rash") && + mods::kept_by_reskate(store, "fourth_studio-board_break") && !mods::kept_by_reskate(store, "Someone-Other_Mod") && + !mods::kept_by_reskate(store, "Fourth_Studio-Board_Break_Pack"), + "The game takes in the official mods in place, and leaves out Mods folder copies of them"); + // Installed again from somewhere: that copy goes, the official one stays. + fs::create_directories(store / L"Fourth_Studio-Road_Rash"); + std::ofstream(store / L"Fourth_Studio-Road_Rash" / L"layout.toc") << "again"; + check(official_mods::adopt_and_retire(store, official).size() == 1 && !fs::exists(store / L"Fourth_Studio-Road_Rash") && + read(adopted / L"layout.toc") == "toc", + "A second copy of an official mod goes, the official one stays"); + check(official_mods::adopt_and_retire(store, official).empty(), "Nothing is done twice"); + } + // Live (RESKATE_OFFICIAL_LIVE=1): the whole of it, against ReSkate and Thunderstore, into a + // scratch Mods folder: the list, each mod's latest version downloaded and put in place. + if (GetEnvironmentVariableW(L"RESKATE_OFFICIAL_LIVE", nullptr, 0)) { + const auto store = root / L"live" / L"Mods"; + fs::create_directories(store); + official_mods::start(store); + check(official_mods::wait(std::chrono::minutes(5)), "The official mods were put in place in time"); + const auto state = official_mods::snapshot(); + std::cout << " official mods from " << state.source << ":\n"; + for (const auto& mod : state.mods) + std::cout << " " << mod.full_name << " v" << mod.installed << " (latest v" << mod.version << "): " << mod.status << '\n'; + for (const auto& pack : mods::official_packs(store)) { + std::size_t files{}; + for (const auto& entry : fs::recursive_directory_iterator(pack.directory)) files += entry.is_regular_file(); + std::cout << " in place: " << pack.name << ", " << files << " files\n"; + } + check(!state.mods.empty() && mods::official_packs(store).size() == state.mods.size(), "Every official mod is in place"); } std::error_code ignored; diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index d1baeb0..f6875bd 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -573,6 +573,8 @@ void mod_icon(ModsPanel& panel, const thunderstore::Package* package, ImVec2 pos void pump_icons(ModsPanel& panel); // The GET MODS page body, under the page header. void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float height, bool installing); +// The official mods (official_mods.h): what every player has, always on, and how each is doing. +void official_page(const Fonts& fonts, ModsPanel& panel, float height); // Everything Thunderstore knows about one package, in a popup over the page. void package_overview(const Fonts& fonts, ModsPanel& panel, ImVec2 size, bool installing); // The README section of an overview: the installed mod's own README.md when it has one, else diff --git a/Launcher/gui_launcher.cpp b/Launcher/gui_launcher.cpp index 1809f23..66479c9 100644 --- a/Launcher/gui_launcher.cpp +++ b/Launcher/gui_launcher.cpp @@ -1,6 +1,7 @@ #include "gui_internal.h" #include "depot_output.h" +#include "official_mods.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Log/logging.h" @@ -511,6 +512,12 @@ bool Launcher::run_mod_merge() { } void Launcher::run_play() { + // The official mods first (official_mods.h): the merge takes them in. + if (!official_mods::wait(std::chrono::milliseconds(0))) { + set(Phase::merging, "Getting official mods", "Downloading the mods every player has", -1.0f); + if (!official_mods::wait(std::chrono::minutes(3))) + logging::write(logging::Level::warning, logging::Channel::launcher, "Official mods were still downloading; Skate starts without them"); + } if (!ignore_mod_problems_ && !run_mod_merge()) return; ignore_mod_problems_ = false; set(Phase::launching, "Starting Skate"); diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 487d0e9..9db2b42 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -1,6 +1,7 @@ #include "gui_internal.h" #include "mod_manager.h" +#include "official_mods.h" #include "Engine/Core/Log/logging.h" @@ -167,6 +168,13 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width, panel.profiles_loaded ? std::to_string(panel.profiles.list.size()) : std::string(), false, std::string())) panel.tab = 2; if (panel.tab == 2) default_focus(); + { + const auto official = official_mods::snapshot(); + if (nav_tile(fonts, width, "OFFICIAL", panel.tab == 3, std::to_string(official.mods.size()), false, + official.syncing ? std::string("Getting the official mods") : std::string())) + panel.tab = 3; + if (panel.tab == 3) default_focus(); + } ImGui::Dummy(ImVec2(0, S(8))); if (!pending.empty()) { @@ -1114,6 +1122,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo const float body = ImGui::GetWindowHeight() - status; if (panel.tab == 1) browse_page(launcher, fonts, panel, body, installing); else if (panel.tab == 2) profiles_page(fonts, panel, window, body, installing); + else if (panel.tab == 3) official_page(fonts, panel, body); else installed_page(launcher, fonts, panel, installed, body, installing); if (!panel.message.empty()) { ImGui::SetCursorPosY(ImGui::GetWindowHeight() - status + S(8)); diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index b30cdf7..c671f6c 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -1,6 +1,6 @@ #include "gui_internal.h" #include "gui_renderer.h" -#include "Engine/Vfs/builtin_packs.h" +#include "official_mods.h" #include "mod_manager.h" #include "updater.h" @@ -216,8 +216,8 @@ std::vector visible_packages(const Store& store, const ts::I // or a category of that name. A deprecated one only shows once installed. if (nsfw(package)) continue; if (package.deprecated && !have) continue; - // ReSkate carries these itself now (Engine/Vfs/builtin_packs.h). - if (mods::superseded_by_builtin(ts::folder_for(package.full_name))) continue; + // Every player has the official mods already (official_mods.h). + if (official_mods::official(package.full_name)) continue; if (!store.category.empty() && !package.in_category(store.category)) continue; if (!query.empty() && lower(package.title()).find(query) == std::string::npos && lower(package.owner).find(query) == std::string::npos && @@ -324,7 +324,7 @@ const thunderstore::Package* package_for(const Store& store, std::string_view fo std::vector updates(const Store& store, const thunderstore::Installed& installed) { std::vector result; for (const auto& package : store.packages) - if (ts::update_available(package, installed) && !mods::superseded_by_builtin(ts::folder_for(package.full_name))) + if (ts::update_available(package, installed) && !official_mods::official(package.full_name)) result.push_back(&package); return result; } @@ -337,8 +337,8 @@ void start_store_install(ModsPanel& panel, std::vector pa std::vector expanded; for (const auto& package : packages) for (auto& needed : thunderstore::with_dependencies(package, panel.store.packages, have)) - // (A mod that needs Board Break or Road Rash has them built in: they are not installed.) - if (!mods::superseded_by_builtin(thunderstore::folder_for(needed.full_name)) && + // (A mod that needs an official mod has it already: it is not installed again.) + if (!official_mods::official(needed.full_name) && std::none_of(expanded.begin(), expanded.end(), [&](const auto& queued) { return queued.full_name == needed.full_name; })) expanded.push_back(std::move(needed)); @@ -457,6 +457,50 @@ void pick(Store& store, const std::string& full_name, bool on) { else if (!on && found != store.picked.end()) store.picked.erase(found); } +void official_page(const Fonts& fonts, ModsPanel& panel, float height) { + // (The listing gives their icons.) + refresh_listing(panel, ImGui::GetTime()); + const auto state = official_mods::snapshot(); + ImGui::PushTextWrapPos(0); + ImGui::TextDisabled("Every player has these, always on: ReSkate keeps them at their latest version from Thunderstore."); + const char* source = state.syncing ? "Getting them now..." + : state.source == "ReSkate" ? "The list comes from ReSkate." + : state.source == "the last list read" ? "ReSkate could not be reached: this is the last list it gave." + : state.source.empty() ? "" : "No list has been read yet: these are the ones ReSkate starts with."; + ImGui::TextDisabled("%s", source); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + ImGui::BeginChild("##official", ImVec2(0, std::max(S(80), height - ImGui::GetCursorPosY())), ImGuiChildFlags_NavFlattened); + const float icon = S(64); + for (const auto& mod : state.mods) { + ImGui::PushID(mod.full_name.c_str()); + const auto* package = ts::find_package(panel.store.packages, mod.full_name); + const auto top = ImGui::GetCursorScreenPos(); + mod_icon(panel, package, top, icon); + ImGui::SetCursorScreenPos(ImVec2(top.x + icon + S(14), top.y)); + ImGui::BeginGroup(); + ImGui::PushFont(fonts.bold); + ImGui::TextUnformatted(mod.title.c_str()); + ImGui::PopFont(); + const auto author = mod.full_name.substr(0, mod.full_name.find('-')); + const auto version = !mod.installed.empty() ? "v" + mod.installed : !mod.version.empty() ? "v" + mod.version : std::string("-"); + ImGui::TextDisabled("%s | by %s | always on", version.c_str(), author.c_str()); + const auto status = mod.working ? (mod.status.empty() ? std::string("Checking for an update...") : mod.status + "...") + : mod.status.empty() ? std::string("Waiting") : mod.status; + const bool fine = !mod.working && mod.status == "Up to date"; + const bool trouble = !mod.working && !fine && !mod.status.empty(); + ImGui::PushTextWrapPos(0); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(fine ? color::good : trouble ? color::warning : color::muted), "%s", status.c_str()); + if (!mod.description.empty()) ImGui::TextWrapped("%s", mod.description.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndGroup(); + ImGui::SetCursorScreenPos(ImVec2(top.x, std::max(ImGui::GetCursorScreenPos().y, top.y + icon) + S(14))); + ImGui::PopID(); + } + if (state.mods.empty()) ImGui::TextDisabled("%s", state.syncing ? "Reading the list..." : "There are no official mods right now."); + ImGui::EndChild(); +} + void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float height, bool installing) { auto& store = panel.store; const auto installed = installed_versions(panel.list); diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index bf8a344..f1de57e 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -6,6 +6,7 @@ #include "Engine/Vfs/world_layer_scan.h" #include "launch.h" #include "mod_manager.h" +#include "official_mods.h" #include "text_encoding.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/runtime.h" @@ -468,10 +469,8 @@ Session open_session(const std::string& log_level) { logging::Channel::launcher, "File logging is unavailable; debugger output remains active."); logging::write(logging::Level::info, logging::Channel::launcher, L"ReSkate launcher starting"); logging::write(logging::Level::info, logging::Channel::launcher, L"Current launch log: " + (session.paths.logs / L"ReSkate.log").wstring()); - // Mods ReSkate now carries itself are taken away before anything reads the Mods folder. - for (const auto& name : launcher_mods::retire_superseded(launcher_mods::mods_root(session.paths.directory))) - logging::write(logging::Level::info, logging::Channel::launcher, - "Mod removed: " + name + " is built into ReSkate now (it is in the Recycle Bin)"); + // The official mods, put in place on a thread of their own while the launcher opens (Play waits). + official_mods::start(launcher_mods::mods_root(session.paths.directory)); return session; } diff --git a/Launcher/mod_manager.cpp b/Launcher/mod_manager.cpp index d6c4e6a..aee3c34 100644 --- a/Launcher/mod_manager.cpp +++ b/Launcher/mod_manager.cpp @@ -1,7 +1,6 @@ #include "mod_manager.h" #include "text_encoding.h" -#include "Engine/Vfs/builtin_packs.h" #include "Engine/Vfs/mod_list.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Platform/launcher_support.h" @@ -404,31 +403,6 @@ std::string install(const fs::path& mods, const fs::path& source, bool replace, } } -std::vector retire_superseded(const fs::path& mods) noexcept { - std::vector retired; - try { - std::vector> found; - std::error_code error; - for (fs::directory_iterator at(mods, error), end; !error && at != end; at.increment(error)) { - if (!at->is_directory(error)) continue; - const auto name = utf8(at->path().filename().wstring()); - if (mods::superseded_by_builtin(name)) found.emplace_back(name, at->path()); - } - for (const auto& [name, path] : found) { - try { - recycle(path); - retired.push_back(name); - } catch (...) {} - } - // mods.json keeps the rest as it was; a malformed one is left for the player to see. - if (!retired.empty()) { - const auto list = mods::scan_mods(mods.parent_path()); - if (list.issue.empty()) mods::save_mod_order(mods, list.entries); - } - } catch (...) {} - return retired; -} - void remove(const fs::path& mods, const std::string& name) { if (!mods::valid_mod_name(name)) fail("\"" + name + "\" is not a mod folder."); const auto target = mods / from_utf8(name); diff --git a/Launcher/mod_manager.h b/Launcher/mod_manager.h index 596b598..98d40e6 100644 --- a/Launcher/mod_manager.h +++ b/Launcher/mod_manager.h @@ -53,10 +53,5 @@ std::string install(const std::filesystem::path& mods_root, const std::filesyste // Moves Mods/ to the Recycle Bin. Throws a user-facing message. void remove(const std::filesystem::path& mods_root, const std::string& name); -// The mods ReSkate now carries itself (Engine/Vfs/builtin_packs.h: Fourth Studio's Board Break and -// Road Rash) go to the Recycle Bin and out of mods.json, so the built-in copy is the one that -// loads. Returns the folders taken away; one that will not go stays (the merge leaves it out). -// Never throws. -std::vector retire_superseded(const std::filesystem::path& mods_root) noexcept; } // namespace dingosdk::launcher_mods diff --git a/Launcher/official_mods.cpp b/Launcher/official_mods.cpp new file mode 100644 index 0000000..eae38bc --- /dev/null +++ b/Launcher/official_mods.cpp @@ -0,0 +1,367 @@ +#include "official_mods.h" +#include "mod_manager.h" +#include "text_encoding.h" +#include "thunderstore.h" +#include "updater.h" + +#include "Engine/Core/Json/json.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Vfs/builtin_packs.h" +#include "Engine/Vfs/mod_list.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace dingosdk::official_mods { +namespace { +using launcher_text::utf8; +using launcher_text::wide; + +constexpr char list_url[] = "https://api.reskate.dev/api/v1/official-mods"; +constexpr char list_file[] = "official-mods.json"; // the last list read, in the official folder +constexpr int list_timeout_ms = 15000; +constexpr int download_timeout_ms = 30000; + +struct Shared { + std::mutex mutex; + std::condition_variable done; + State state; + bool started{}; + std::atomic cancel{}; +}; +Shared& shared() { + static auto* value = new Shared; + return *value; +} +void log(logging::Level level, const std::string& text) { logging::write(level, logging::Channel::launcher, text); } + +std::string get(const std::string& url, std::uint64_t limit, int timeout_ms) { + std::string body; + launcher_update::http_stream(wide(url), limit, timeout_ms, [&](const char* data, DWORD size) { body.append(data, size); }); + return body; +} +std::string read_text(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} +std::string trimmed(std::string text) { + while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == ' ')) text.pop_back(); + return text; +} +// "Namespace-Name" with a name of letters, digits and underscores on each side: nothing else may +// become a folder name or a URL path here. +bool plain_full_name(std::string_view name) { + const auto dash = name.find('-'); + if (dash == std::string_view::npos || !dash || dash + 1 >= name.size() || name.find('-', dash + 1) != std::string_view::npos) + return false; + return std::all_of(name.begin(), name.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + }); +} +// An https URL on thunderstore.io or one of its hosts: nothing else is downloaded. +bool thunderstore_url(std::string_view url) { + if (!url.starts_with("https://")) return false; + const auto host = url.substr(8, url.find('/', 8) == std::string_view::npos ? std::string_view::npos : url.find('/', 8) - 8); + return host == "thunderstore.io" || host.ends_with(".thunderstore.io"); +} +std::string title_of(std::string_view full_name) { + std::string title(full_name.substr(full_name.find('-') + 1)); + std::replace(title.begin(), title.end(), '_', ' '); + return title; +} +fs::path official_root(const fs::path& mods_root) { return mods_root / std::string(mods::official_folder); } +std::string stamp_of(const fs::path& folder) { return trimmed(read_text(folder / std::string(mods::official_stamp))); } +void write_stamp(const fs::path& folder, const std::string& version) { + std::ofstream(folder / std::string(mods::official_stamp), std::ios::binary | std::ios::trunc) << (version.empty() ? "0" : version) << '\n'; +} +// The version a mod's own manifest.json gives, or empty. +std::string manifest_version(const fs::path& folder) { + try { + const auto manifest = Json::parse(read_text(folder / L"manifest.json")); + return manifest.is_object() ? manifest.value("version_number", "") : std::string{}; + } catch (...) { + return {}; + } +} +void update(const std::string& full_name, const std::function& change) { + auto& s = shared(); + std::lock_guard lock(s.mutex); + for (auto& mod : s.state.mods) + if (mod.full_name == full_name) change(mod); +} +// The latest version Thunderstore lists for a mod the list named without one. +void ask_thunderstore(Mod& mod) { + const auto dash = mod.full_name.find('-'); + const auto url = std::format("{}/api/experimental/package/{}/{}/", thunderstore::site, mod.full_name.substr(0, dash), + mod.full_name.substr(dash + 1)); + const auto package = Json::parse(get(url, 4ull << 20, list_timeout_ms)); + const auto& latest = package.at("latest"); + const auto download = latest.value("download_url", ""); + if (!thunderstore_url(download)) throw std::runtime_error("Thunderstore gave no download for it"); + mod.version = latest.value("version_number", ""); + mod.download_url = download; + if (mod.description.empty()) mod.description = latest.value("description", ""); + if (mod.icon.empty()) mod.icon = latest.value("icon", ""); +} +// Downloads `mod` and puts it in place of the version there, whole or not at all. +void download(const fs::path& root, const Mod& mod, const std::atomic& cancel) { + const auto cache = root / L".reskate-download"; + std::error_code error; + fs::create_directories(cache, error); + const auto archive = cache / wide(mod.full_name + ".zip"); + struct Cleanup { + fs::path archive; + ~Cleanup() { std::error_code ignored; fs::remove(archive, ignored); } + } cleanup{archive}; + { + std::ofstream file(archive, std::ios::binary | std::ios::trunc); + if (!file) throw std::runtime_error("the download could not be written"); + launcher_update::http_stream(wide(mod.download_url), 4ull << 30, download_timeout_ms, [&](const char* data, DWORD size) { + if (cancel) throw std::runtime_error("stopped"); + file.write(data, size); + if (!file) throw std::runtime_error("the download could not be written (is the disk full?)"); + }); + } + // Installed beside the one in place, then swapped in. + const auto staging = mod.full_name + ".new"; + fs::remove_all(root / wide(staging), error); + launcher_mods::InstallOptions options; + options.folder = staging; + options.author = mod.full_name.substr(0, mod.full_name.find('-')); + options.require_content = true; + launcher_mods::install(root, archive, false, {}, cancel, options); + const auto target = root / wide(mod.full_name); + fs::remove_all(target, error); + fs::rename(root / wide(staging), target, error); + if (error) { + fs::remove_all(root / wide(staging), error); + throw std::runtime_error("the new version could not be put in place"); + } + write_stamp(target, mod.version); +} + +void sync(const fs::path& mods_root) { + auto& s = shared(); + const auto root = official_root(mods_root); + std::error_code error; + fs::create_directories(root, error); + + // The list: ReSkate's, else the last one read, else ReSkate's own. + std::vector list; + std::string source; + bool authoritative = true; + try { + const auto body = get(list_url, 4ull << 20, list_timeout_ms); + list = parse_list(body); + if (list.empty() && body.find("\"mods\"") == std::string::npos) throw std::runtime_error("not a list of official mods"); + std::ofstream(root / list_file, std::ios::binary | std::ios::trunc) << body; + source = "ReSkate"; + } catch (const std::exception& failure) { + log(logging::Level::warning, std::string("Official mods: the list could not be read from ReSkate (") + failure.what() + ")"); + list = parse_list(read_text(root / list_file)); + source = "the last list read"; + if (list.empty() && !fs::exists(root / list_file, error)) { + list = fallback_list(); + source = "ReSkate's own list"; + authoritative = false; + } + } + for (auto& mod : list) mod.installed = stamp_of(root / wide(mod.full_name)); + { + std::lock_guard lock(s.mutex); + s.state.mods = list; + s.state.source = source; + } + log(logging::Level::info, std::format("Official mods: {} from {}", list.size(), source)); + + for (const auto& line : adopt_and_retire(mods_root, list)) log(logging::Level::info, "Official mods: " + line); + for (auto& mod : list) { + if (s.cancel) break; + mod.installed = stamp_of(root / wide(mod.full_name)); + update(mod.full_name, [&](Mod& shown) { shown.installed = mod.installed; shown.working = true; }); + std::string status; + try { + if (mod.version.empty() || mod.download_url.empty()) ask_thunderstore(mod); + if (mod.installed != mod.version) { + update(mod.full_name, [&](Mod& shown) { + shown.version = mod.version; + shown.status = mod.installed.empty() ? "Downloading v" + mod.version : "Updating to v" + mod.version; + }); + download(root, mod, s.cancel); + log(logging::Level::info, std::format("Official mods: {} v{} {}", mod.full_name, mod.version, + mod.installed.empty() ? "installed" : "updated from v" + mod.installed)); + mod.installed = mod.version; + } + status = "Up to date"; + } catch (const std::exception& failure) { + log(logging::Level::warning, std::format("Official mods: {} could not be brought up to date: {}", mod.full_name, failure.what())); + status = mod.installed.empty() ? "Not downloaded yet: " + std::string(failure.what()) + : "Could not check for an update; v" + mod.installed + " stays"; + } + update(mod.full_name, [&](Mod& shown) { + shown = mod; + shown.status = status; + shown.working = false; + }); + } + // An official mod the list no longer names goes (it came from ReSkate, not the player). + if (authoritative && !s.cancel) { + std::set named; + for (const auto& mod : list) named.insert(mod.full_name); + for (fs::directory_iterator at(root, error), end; !error && at != end; at.increment(error)) { + const auto name = utf8(at->path().filename().wstring()); + if (!at->is_directory(error) || name.starts_with('.') || named.contains(name)) continue; + std::error_code ignored; + fs::remove_all(at->path(), ignored); + log(logging::Level::info, "Official mods: " + name + " is no longer official and was removed"); + } + } +} +} // namespace + +std::vector parse_list(std::string_view json) { + std::vector list; + try { + const auto answer = Json::parse(json); + if (!answer.is_object() || !answer.contains("mods") || !answer.at("mods").is_array()) return {}; + const auto& mods = answer.at("mods"); + std::set seen; + for (std::size_t i = 0; i < mods.size(); ++i) { + const auto& row = mods.at(i); + if (!row.is_object()) continue; + Mod mod; + mod.full_name = row.value("full_name", ""); + if (!plain_full_name(mod.full_name) || !seen.insert(mod.full_name).second) continue; + mod.title = row.value("title", ""); + if (mod.title.empty()) mod.title = title_of(mod.full_name); + mod.description = row.value("description", ""); + mod.icon = row.value("icon", ""); + if (!thunderstore_url(mod.icon)) mod.icon.clear(); + mod.version = row.value("version", ""); + mod.download_url = row.value("download_url", ""); + if (!thunderstore_url(mod.download_url) || mod.version.empty()) { + mod.version.clear(); + mod.download_url.clear(); + } + list.push_back(std::move(mod)); + } + } catch (...) { + return {}; + } + return list; +} + +std::vector fallback_list() { + std::vector list; + for (const auto* name : {"Fourth_Studio-Board_Break_Pack", "Fourth_Studio-Road_Rash"}) { + Mod mod; + mod.full_name = name; + mod.title = title_of(name); + list.push_back(std::move(mod)); + } + return list; +} + +std::vector adopt_and_retire(const fs::path& mods_root, const std::vector& official) { + std::vector done; + try { + const auto root = official_root(mods_root); + std::error_code error; + fs::create_directories(root, error); + std::vector gone; + for (const auto& mod : official) { + const auto copy = mods_root / wide(mod.full_name); + if (!fs::is_directory(copy, error)) continue; + const auto target = root / wide(mod.full_name); + if (stamp_of(target).empty()) { + // Installed by the player already: moved over rather than downloaded again. + const auto version = manifest_version(copy); + fs::remove_all(target, error); + fs::rename(copy, target, error); + if (!error) { + write_stamp(target, version); + gone.push_back(mod.full_name); + done.push_back(std::format("{} (v{}) moved from the Mods folder", mod.full_name, version.empty() ? "?" : version)); + continue; + } + error.clear(); + } + try { + launcher_mods::remove(mods_root, mod.full_name); + gone.push_back(mod.full_name); + done.push_back(mod.full_name + ": the copy in the Mods folder went to the Recycle Bin"); + } catch (...) {} + } + for (fs::directory_iterator at(mods_root, error), end; !error && at != end; at.increment(error)) { + const auto name = utf8(at->path().filename().wstring()); + if (!at->is_directory(error) || !mods::retired_mod(name)) continue; + try { + launcher_mods::remove(mods_root, name); + gone.push_back(name); + done.push_back(name + " is retired and went to the Recycle Bin"); + } catch (...) {} + } + // mods.json keeps the rest as it was; a malformed one is left for the player to see. + if (!gone.empty()) { + const auto list = mods::scan_mods(mods_root.parent_path()); + if (list.issue.empty()) mods::save_mod_order(mods_root, list.entries); + } + } catch (...) {} + return done; +} + +void start(const fs::path& mods_root) { + auto& s = shared(); + { + std::lock_guard lock(s.mutex); + if (s.started) return; + s.started = true; + s.state.syncing = true; + } + std::thread([mods_root] { + try { + sync(mods_root); + } catch (const std::exception& failure) { + log(logging::Level::warning, std::string("Official mods could not be put in place: ") + failure.what()); + } catch (...) {} + auto& s = shared(); + { + std::lock_guard lock(s.mutex); + s.state.syncing = false; + s.state.synced = true; + } + s.done.notify_all(); + }).detach(); +} + +bool wait(std::chrono::milliseconds timeout) { + auto& s = shared(); + std::unique_lock lock(s.mutex); + return s.done.wait_for(lock, timeout, [&] { return s.state.synced; }); +} + +State snapshot() { + auto& s = shared(); + std::lock_guard lock(s.mutex); + return s.state; +} + +bool official(std::string_view full_name) { + if (mods::retired_mod(thunderstore::folder_for(full_name))) return true; + auto& s = shared(); + std::lock_guard lock(s.mutex); + const auto& mods = s.state.mods.empty() ? fallback_list() : s.state.mods; + return std::any_of(mods.begin(), mods.end(), [&](const Mod& mod) { return mod.full_name == full_name; }); +} +} // namespace dingosdk::official_mods diff --git a/Launcher/official_mods.h b/Launcher/official_mods.h new file mode 100644 index 0000000..f28256f --- /dev/null +++ b/Launcher/official_mods.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Official mods: Thunderstore packages every player has, always on, at their latest version. The +// ReSkate backend names them (GET /api/v1/official-mods, beside the verified mods), so one is +// added or dropped without a release; the launcher keeps the last list it read, and has one of its +// own for a first launch with no network. They live in Mods/.reskate-official/ +// (Engine/Vfs/builtin_packs.h), which the game merges after every installed mod. +// +// As the launcher starts it reads the list and, on a thread of its own, puts each mod in place: a +// copy the player installed from Thunderstore is moved over rather than downloaded again, a newer +// version is downloaded, and copies left in the Mods folder (and mods retired with an official +// one) go to the Recycle Bin and out of mods.json. Play waits for it. +namespace dingosdk::official_mods { +struct Mod { + std::string full_name; // Namespace-Name + std::string title, description, icon; + std::string version; // the latest the list or Thunderstore gave; empty when not known + std::string download_url; + std::string installed; // the version in place; empty when none is + std::string status; // what the launcher did with it, for the OFFICIAL tab + bool working{}; // being downloaded or put in place now +}; +struct State { + std::vector mods; + std::string source; // where the list came from: "ReSkate", "the last list read" or "ReSkate's own list" + bool syncing{}, synced{}; +}; + +// Puts the official mods in place under `mods_root`, once a launch (later calls do nothing). +void start(const std::filesystem::path& mods_root); +// Waits for that, at most `timeout`. False while it still runs (or never started). +bool wait(std::chrono::milliseconds timeout); +State snapshot(); +// Whether the Thunderstore package `full_name` is official (or retired with one): the browser +// neither lists nor installs it. +bool official(std::string_view full_name); + +// The pieces, for tests. +// The backend's answer: its mods, or none when it is not that answer. +std::vector parse_list(std::string_view json); +// The list ReSkate falls back on when it has never read one: Fourth Studio's Board Break pack and +// Road Rash. +std::vector fallback_list(); +// For each official mod not in place yet, a copy in the Mods folder is moved in (stamped with its +// manifest's version); the copies left in the Mods folder and retired mods go to the Recycle Bin; +// mods.json forgets what went. Returns what was done, a line each. Never throws. +std::vector adopt_and_retire(const std::filesystem::path& mods_root, const std::vector& official); +} diff --git a/assets/builtin/board_break.zip b/assets/builtin/board_break.zip deleted file mode 100644 index b5b9af1..0000000 Binary files a/assets/builtin/board_break.zip and /dev/null differ diff --git a/assets/builtin/fpv_drone.zip b/assets/builtin/fpv_drone.zip deleted file mode 100644 index 0086a6e..0000000 Binary files a/assets/builtin/fpv_drone.zip and /dev/null differ diff --git a/assets/builtin/road_rash.zip b/assets/builtin/road_rash.zip deleted file mode 100644 index 09807d4..0000000 Binary files a/assets/builtin/road_rash.zip and /dev/null differ diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 46d5327..f498d5b 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -3,7 +3,8 @@ if(WIN32) Launcher/gui.cpp Launcher/gui_launcher.cpp Launcher/gui_renderer.cpp Launcher/gui_home.cpp Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp Launcher/gui_mods_profiles.cpp Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/gui_changelog.cpp Launcher/game_settings.cpp - Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/mod_profiles.cpp Launcher/thunderstore.cpp Launcher/problem.h) + Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/mod_profiles.cpp Launcher/thunderstore.cpp Launcher/official_mods.cpp + Launcher/problem.h) target_link_libraries(dingosdk_launcher PRIVATE dingosdk_logging dingosdk_content_cache_install dingosdk_world_layer_scan dingosdk_launcher_support dingosdk_initfs dingosdk_mod_list dingosdk_mods dingosdk_json dingosdk_miniz dingosdk_imgui dingosdk_playstation_input winhttp shell32 dwmapi windowscodecs ole32) set_target_properties(dingosdk_launcher PROPERTIES OUTPUT_NAME "ReSkateLauncher") @@ -23,8 +24,9 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) add_dependencies(dingosdk_oodle_path_tests dingosdk_test_oodle_codec) add_test(NAME oodle_path COMMAND dingosdk_oodle_path_tests "$") add_executable(dingosdk_mod_manager_tests Launcher/Test/mod_manager_tests.cpp Launcher/mod_manager.cpp - Engine/Vfs/builtin_packs.cpp) - target_link_libraries(dingosdk_mod_manager_tests PRIVATE dingosdk_mod_list dingosdk_launcher_support dingosdk_miniz dingosdk_zstd shell32) + Launcher/official_mods.cpp Launcher/updater.cpp Launcher/thunderstore.cpp Engine/Vfs/builtin_packs.cpp) + target_link_libraries(dingosdk_mod_manager_tests PRIVATE dingosdk_mod_list dingosdk_launcher_support dingosdk_logging dingosdk_json + dingosdk_miniz dingosdk_zstd winhttp shell32) add_test(NAME launcher_mod_manager COMMAND dingosdk_mod_manager_tests) add_executable(dingosdk_thunderstore_tests Launcher/Test/thunderstore_tests.cpp Launcher/thunderstore.cpp) target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz) diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 7a7f1ba..5c6c910 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -320,13 +320,14 @@ if(EXISTS "${dingosdk_emotes}/emotes.json" AND EXISTS "${dingosdk_emotes}/emotes endif() # Game content ReSkate carries itself (Engine/Vfs/builtin_packs.h), one zip each in assets/builtin: # the FPV drone's model (tools/models/build_drone_pack.py; without it other players' drones use a -# stand-in prop), and Fourth Studio's Board Break and Road Rash packs. A missing zip is left out. +# stand-in prop). A missing zip is left out. (Mods that are content for ReSkate's features, such +# as Board Break's decks, are official mods the launcher downloads: Launcher/official_mods.h.) # Each zip is made into a zstd pack by ReSkateBuiltinPacker as the build goes (again only when the # zip changes), which is what the DLL carries: compressed whole, a pack's repeated files cost -# next to nothing (Board Break's ten decks). +# next to nothing. set(dingosdk_builtin_rc "") set(dingosdk_builtin_packs "") -foreach(pack IN ITEMS "FPV_DRONE fpv_drone" "BOARD_BREAK board_break" "ROAD_RASH road_rash") +foreach(pack IN ITEMS "FPV_DRONE fpv_drone") separate_arguments(pack) list(GET pack 0 pack_resource) list(GET pack 1 pack_file)