mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 12:57:01 +02:00
Official mods: Board Break and Road Rash downloaded from Thunderstore and kept at their latest, with an Official tab
The launcher reads the official list from the ReSkate backend (or the last one it read, or its own) and keeps each mod in Mods/.reskate-official at its latest version; a copy the player installed is moved in, the deprecated Board Break retired. The game loads them always on and leaves out Mods folder copies; the browser no longer lists them. The built-in zips are gone (FPV drone too), and built-in folders no longer carried are removed.
This commit is contained in:
1 parent
43b998f285
commit
a2d6daffbc
18 files changed
+649
-89
No files matched your search
@@ -1,5 +1,6 @@
|
||||
#include "builtin_packs.h"
|
||||
#include "builtin_pack_format.h"
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
#include <Windows.h>
|
||||
#include <miniz.h>
|
||||
#include <zstd.h>
|
||||
@@ -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<char>(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<const unsigned char> 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<char>(std::tolower(c)); });
|
||||
return std::find(std::begin(superseded), std::end(superseded), name) != std::end(superseded);
|
||||
std::vector<BuiltinPack> official_packs(const fs::path& mods_root) noexcept {
|
||||
std::vector<BuiltinPack> 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<char>(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<BuiltinPack> unpack_builtin_packs(const fs::path& mods_root, std::vector<std::string>& notes) noexcept {
|
||||
@@ -229,8 +260,8 @@ std::vector<BuiltinPack> 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<BuiltinPack> 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<fs::path> 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());
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
#include <vector>
|
||||
|
||||
// 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/<name> (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/<name> (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<BuiltinPack> 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<const unsigned char> 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/<Namespace-Name>, 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<BuiltinPack> 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/<folder>) 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
|
||||
@@ -142,11 +142,12 @@ std::vector<fs::path> 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 <folder>#<deck>, so
|
||||
// that a pack's decks are chosen, set aside and switched like decks in folders of their own.
|
||||
|
||||
@@ -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("<html>go away</html>").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<std::string>{"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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<const ts::Package*> 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<const thunderstore::Package*> updates(const Store& store, const thunderstore::Installed& installed) {
|
||||
std::vector<const ts::Package*> 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<thunderstore::Package> pa
|
||||
std::vector<thunderstore::Package> 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);
|
||||
|
||||
+3
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<std::string> retire_superseded(const fs::path& mods) noexcept {
|
||||
std::vector<std::string> retired;
|
||||
try {
|
||||
std::vector<std::pair<std::string, fs::path>> 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);
|
||||
|
||||
@@ -53,10 +53,5 @@ std::string install(const std::filesystem::path& mods_root, const std::filesyste
|
||||
// Moves Mods/<name> 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<std::string> retire_superseded(const std::filesystem::path& mods_root) noexcept;
|
||||
|
||||
} // namespace dingosdk::launcher_mods
|
||||
@@ -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 <Windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
|
||||
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<bool> 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<char>(input), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
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<void(Mod&)>& 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<bool>& 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<Mod> 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<std::string> 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<Mod> parse_list(std::string_view json) {
|
||||
std::vector<Mod> 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<std::string> 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<Mod> fallback_list() {
|
||||
std::vector<Mod> 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<std::string> adopt_and_retire(const fs::path& mods_root, const std::vector<Mod>& official) {
|
||||
std::vector<std::string> done;
|
||||
try {
|
||||
const auto root = official_root(mods_root);
|
||||
std::error_code error;
|
||||
fs::create_directories(root, error);
|
||||
std::vector<std::string> 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
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// 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/<Namespace-Name>
|
||||
// (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<Mod> 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<Mod> 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<Mod> 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<std::string> adopt_and_retire(const std::filesystem::path& mods_root, const std::vector<Mod>& official);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
-3
@@ -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 "$<TARGET_FILE_DIR:dingosdk_test_oodle_codec>")
|
||||
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)
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user