From fffd34ca8035582fa6307e8104c6a7cab350db18 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 18 Oct 2026 16:33:53 -0500 Subject: [PATCH] Mods: conflicts resolved in MY MODS, official mods live in Mods, and big maps merge faster MY MODS: a table with an animated drag for the load order, a conflicts bar with Check conflicts, and a Resolve popup whose choices are kept. The check runs at startup and after changes; conflicts no longer show as problems, block Play, or hold mod files during an update. Conflicts: only game EBX that cannot be combined, or Lua scripts with different versions; colourways and same-name additions are not conflicts. Official mods: installed in Mods/ and turned on or off through mods.json, so they show in MY MODS; the old .reskate-official folder is moved over. Merge: a scan cache (Mods/.reskate-scan.txt) keeps each unchanged mod's repair, mesh and texture scans, so big maps are not re-read every merge. Launcher: an announcement from the backend shows under STATUS, with HIDE. --- Engine/Vfs/Test/mod_conflicts_tests.cpp | 77 ++ Engine/Vfs/builtin_packs.cpp | 110 ++- Engine/Vfs/builtin_packs.h | 35 +- Engine/Vfs/game_archives.cpp | 7 + Engine/Vfs/game_archives.h | 3 + Engine/Vfs/mod_catalog.cpp | 57 +- Engine/Vfs/mod_conflicts.h | 70 ++ Engine/Vfs/mod_item_sources.h | 2 +- Engine/Vfs/mod_list.cpp | 34 + Engine/Vfs/mod_list.h | 8 + Engine/Vfs/mod_merge.cpp | 20 +- Engine/Vfs/mod_merge.h | 3 +- Engine/Vfs/mod_merge_combine.cpp | 113 +-- Engine/Vfs/mod_merge_conflicts.cpp | 498 ++++++++----- Engine/Vfs/mod_merge_internal.h | 58 +- Engine/Vfs/mod_merge_scan_cache.cpp | 113 +++ Engine/Vfs/mod_merge_stamp.cpp | 9 + Engine/Vfs/mod_merge_textures.cpp | 4 +- Extension/Assets/mod_layers.cpp | 6 +- Extension/UI/Overlay/modding_menu.cpp | 59 +- Launcher/Test/mod_manager_tests.cpp | 97 ++- Launcher/gui_home.cpp | 131 +++- Launcher/gui_internal.h | 71 +- Launcher/gui_launcher.cpp | 15 +- Launcher/gui_mods.cpp | 924 ++++++++++++++++++++---- Launcher/gui_mods_browse.cpp | 9 +- Launcher/official_mods.cpp | 86 ++- Launcher/official_mods.h | 12 +- cmake/Apps.cmake | 3 + cmake/Core.cmake | 1 + 30 files changed, 2019 insertions(+), 616 deletions(-) create mode 100644 Engine/Vfs/Test/mod_conflicts_tests.cpp create mode 100644 Engine/Vfs/mod_conflicts.h create mode 100644 Engine/Vfs/mod_merge_scan_cache.cpp diff --git a/Engine/Vfs/Test/mod_conflicts_tests.cpp b/Engine/Vfs/Test/mod_conflicts_tests.cpp new file mode 100644 index 00000000..c13472b6 --- /dev/null +++ b/Engine/Vfs/Test/mod_conflicts_tests.cpp @@ -0,0 +1,77 @@ +// Checks a data root's enabled mods for conflicts the way the launcher's mod list does +// (mods::ConflictChecker): once, again with nothing changed, and again with the load order +// reversed, saying how long each took and which mods each would leave out. The second check +// has to find what the first did, and both later ones have to be quick: nothing is read again. +// First argument: a folder holding the game's Data/ and a Mods/ folder. Skips when none is given. +#include "Engine/Vfs/mod_conflicts.h" +#include "Engine/Vfs/mod_list.h" + +#include + +#include +#include +#include +#include +#include + +namespace mods = dingosdk::mods; + +namespace { +void say(const char* what, const std::vector& order, const std::vector& conflicts, + std::chrono::steady_clock::duration took) { + std::cout << what << ": " << conflicts.size() << " conflicting pair(s) in " + << std::chrono::duration_cast(took).count() << " ms\n"; + for (const auto& [lost, loss] : mods::conflict_losses(order, conflicts)) { + std::string areas; + for (const auto& area : loss.rows.front()->areas) areas += (areas.empty() ? "" : ", ") + area; + std::cout << " left out: " << lost << " (loses to " << loss.winner << ": " << areas << ")\n"; + } +} +} // namespace + +int main(int argc, char** argv) { + if (argc < 2 || !std::filesystem::exists(std::filesystem::path(argv[1]) / "Data")) { + std::cout << "No data root given; skipped.\n"; + return 0; + } + const std::filesystem::path root(argv[1]); + const auto list = mods::scan_mods(root); + std::vector enabled; + for (const auto& entry : list.entries) + if (entry.enabled && entry.mod.provides_layout && entry.mod.outdated.empty()) enabled.push_back(&entry.mod); + const auto threads = std::max(1U, std::thread::hardware_concurrency()) - 1; + mods::ConflictChecker checker(root); + const auto timed = [&](const std::vector& order, const char* what) { + std::vector names; + for (const auto* mod : order) names.push_back(mod->name); + const auto start = std::chrono::steady_clock::now(); + auto conflicts = checker.check(order, threads); + say(what, names, conflicts, std::chrono::steady_clock::now() - start); + return conflicts; + }; + std::cout << enabled.size() << " enabled mod(s)\n"; + const auto first = timed(enabled, "first check"); + const auto again = timed(enabled, "again, nothing changed"); + auto reversed = enabled; + std::reverse(reversed.begin(), reversed.end()); + (void)timed(reversed, "load order reversed"); + // Nothing is held open between checks: every mod's archives can be taken alone, as an update + // replacing them needs. + std::size_t held{}, tried{}; + for (const auto* mod : enabled) { + std::error_code error; + for (std::filesystem::recursive_directory_iterator at(mod->directory, error), end; !error && at != end; at.increment(error)) { + if (at->path().extension() != L".cas") continue; + ++tried; + const auto file = CreateFileW(at->path().c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, 0, nullptr); + if (file == INVALID_HANDLE_VALUE) ++held; + else CloseHandle(file); + } + } + std::cout << tried << " mod archive(s) tried after the checks, " << held << " still held\n"; + const bool same = held == 0 && first.size() == again.size() && + std::equal(first.begin(), first.end(), again.begin(), [](const auto& a, const auto& b) { + return a.kept == b.kept && a.lost == b.lost && a.assets == b.assets; }); + std::cout << (same ? "mod conflicts: ok\n" : "mod conflicts: the second check differs from the first, or a file is held\n"); + return same ? 0 : 1; +} diff --git a/Engine/Vfs/builtin_packs.cpp b/Engine/Vfs/builtin_packs.cpp index a2b09c60..800a9222 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 "mod_list.h" #include "Engine/Core/Platform/path_text.h" #include #include @@ -236,68 +237,71 @@ std::vector official_off_list(const fs::path& mods_root) { } } // namespace -bool official_off(const fs::path& mods_root, std::string_view folder) noexcept { +std::vector move_official_mods(const fs::path& mods_root) noexcept { + std::vector done; try { + const auto kept = mods_root / std::string(official_folder); + std::error_code error; + if (!fs::is_directory(kept, error)) return done; const auto off = official_off_list(mods_root); - return !folder.empty() && std::find(off.begin(), off.end(), lower_text(folder)) != off.end(); - } catch (...) { - return false; - } + bool listed = true; + for (fs::directory_iterator at(kept, error), end; !error && at != end; at.increment(error)) { + const auto wide = at->path().filename().wstring(); + if (!at->is_directory(error) || wide.empty() || wide.front() == L'.' || wide.find(L".new") != std::wstring::npos || + !official_mod(at->path())) + continue; + const auto name = path_utf8(at->path().filename()); + const auto target = mods_root / at->path().filename(); + std::error_code moved; + if (fs::exists(target, moved)) { + // Moved before, by a launcher that stopped before it was done: this copy is left over. + if (official_mod(target)) { + fs::remove_all(at->path(), moved); + continue; + } + // A copy of the player's own, which the official one replaces as it did from there. + fs::remove_all(target, moved); + if (moved) { + done.push_back(name + " could not be moved into the Mods folder: a copy there could not be replaced"); + continue; + } + } + fs::rename(at->path(), target, moved); + if (moved) { + done.push_back(name + " could not be moved into the Mods folder (" + moved.message() + ")"); + continue; + } + const bool on = std::find(off.begin(), off.end(), lower_text(name)) == off.end(); + listed = set_mod_enabled(mods_root, name, on) && listed; + done.push_back(name + " moved into the Mods folder" + (on ? "" : ", switched off as it was")); + } + // The list of those turned off is in mods.json now. + if (listed) fs::remove(kept / std::string(official_off_file), error); + } catch (...) {} + return done; +} + +bool official_mod(const fs::path& directory) noexcept { + std::error_code error; + return fs::is_regular_file(directory / std::string(official_stamp), error); +} + +bool official_off(const fs::path& mods_root, std::string_view folder) noexcept { + return !folder.empty() && mod_disabled(mods_root, folder); } bool set_official_off(const fs::path& mods_root, std::string_view folder, bool off) noexcept { - try { - if (folder.empty() || folder.find_first_of("\r\n/\\") != std::string_view::npos) return false; - auto names = official_off_list(mods_root); - const auto name = lower_text(folder); - std::erase(names, name); - if (off) names.push_back(name); - const auto root = mods_root / std::string(official_folder); - std::error_code error; - fs::create_directories(root, error); - // Written beside, then put in place: a reader never sees half a list. - const auto path = root / std::string(official_off_file); - const auto temporary = root / (std::string(official_off_file) + ".new"); - { - std::ofstream out(temporary, std::ios::binary | std::ios::trunc); - for (const auto& each : names) out << each << '\n'; - if (!out) return false; - } - return MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != FALSE; - } catch (...) { - return false; - } + return set_mod_enabled(mods_root, folder, !off); } bool official_in_use(const fs::path& mods_root, std::string_view folder) noexcept { try { - std::error_code error; - return !folder.empty() && !official_off(mods_root, folder) && - fs::is_regular_file(mods_root / std::string(official_folder) / std::string(folder) / std::string(official_stamp), error); + return !folder.empty() && official_mod(mods_root / std::string(folder)) && !official_off(mods_root, folder); } catch (...) { return false; } } -std::vector official_packs(const fs::path& mods_root, bool with_off) noexcept { - std::vector packs; - try { - const auto off = with_off ? std::vector{} : official_off_list(mods_root); - 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) : '_'); - if (std::find(off.begin(), off.end(), lower_text(name)) != off.end()) continue; - 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); @@ -306,14 +310,8 @@ bool retired_mod(std::string_view folder) noexcept { } } -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; - } +bool kept_by_reskate(const fs::path&, std::string_view folder) noexcept { + return !folder.empty() && retired_mod(folder); } std::vector unpack_builtin_packs(const fs::path& mods_root, std::vector& notes) noexcept { diff --git a/Engine/Vfs/builtin_packs.h b/Engine/Vfs/builtin_packs.h index e32800de..da1cc0d8 100644 --- a/Engine/Vfs/builtin_packs.h +++ b/Engine/Vfs/builtin_packs.h @@ -25,33 +25,36 @@ std::vector unpack_builtin_packs(const std::filesystem::path& mods_ bool unpack_builtin_pack(std::span bytes, const std::filesystem::path& target, std::string& issue) noexcept; // Official mods (Launcher/official_mods.h): Thunderstore packages the ReSkate backend names, which -// the player installs on the launcher's OFFICIAL tab into Mods/.reskate-official/, -// each with a stamp file holding its version (written last, so a folder without one is not a whole -// mod). Merged after every installed mod, like the packs above, unless turned off. Not in -// mods.json, and no mod list shows them. -inline constexpr std::string_view official_folder = ".reskate-official"; +// the player installs on the launcher's OFFICIAL tab. Each is a mod like any other, in +// Mods/ and in mods.json (switched on and off there, and placed in the load +// order), with a stamp file holding its version (written last, so a folder without one is not a +// whole official mod). One switched off does not load, and the feature it is made for does not start. inline constexpr std::string_view official_stamp = ".official-version"; -// The official mods the player turned off (the launcher's OFFICIAL tab): their folder names, one a -// line, in Mods/.reskate-official/official-off.txt. One that is off stays installed, but nothing -// merges or lists it, and the feature it is made for does not start. +// Where official mods were kept before they were mods like any other: Mods/.reskate-official, with +// the ones turned off listed in its official-off.txt. move_official_mods takes them out of it; the +// launcher keeps the last list of official mods it read there. +inline constexpr std::string_view official_folder = ".reskate-official"; inline constexpr std::string_view official_off_file = "official-off.txt"; // The official mods the features are made for, by folder name (Namespace-Name). inline constexpr std::string_view board_break_official = "Fourth_Studio-Board_Break_Pack"; inline constexpr std::string_view road_rash_official = "Fourth_Studio-Road_Rash"; -// The official mods in place under `mods_root`, by folder name; without those turned off unless -// `with_off`. Never throws. -std::vector official_packs(const std::filesystem::path& mods_root, bool with_off = false) noexcept; -// Whether the player turned the official mod `folder` off. Never throws. +// Moves the official mods kept in Mods/.reskate-official into the Mods folder, each at the bottom +// of the load order (where they loaded from there), switched off when they were turned off there. +// Says what it did, a line each. Does nothing once there is nothing left there. Never throws. +std::vector move_official_mods(const std::filesystem::path& mods_root) noexcept; +// Whether the mod folder `directory` is an official mod (it has its stamp). Never throws. +bool official_mod(const std::filesystem::path& directory) noexcept; +// Whether the player switched the official mod `folder` off (in mods.json). Never throws. bool official_off(const std::filesystem::path& mods_root, std::string_view folder) noexcept; -// Turns it off or back on; false when the list could not be written. Never throws. +// Switches it off or back on; false when mods.json could not be written. Never throws. bool set_official_off(const std::filesystem::path& mods_root, std::string_view folder, bool off) noexcept; // Whether the official mod `folder` is installed (the player installs them on the launcher's -// OFFICIAL tab) and not turned off: what the feature it is made for needs to start. Never throws. +// OFFICIAL tab) and not switched off: what the feature it is made for needs to start. Never throws. bool official_in_use(const std::filesystem::path& mods_root, std::string_view folder) 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. +// Whether the installed mod `folder` (Mods/) is left out because ReSkate sees to it: 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/game_archives.cpp b/Engine/Vfs/game_archives.cpp index e6cdfd4e..99dd754a 100644 --- a/Engine/Vfs/game_archives.cpp +++ b/Engine/Vfs/game_archives.cpp @@ -61,9 +61,16 @@ void GameArchives::forget(const fs::path& file) const { open_->files.erase(found); } } +void GameArchives::close_all() const { + if (!open_) return; + std::lock_guard lock(open_->mutex); + for (const auto& [path, file] : open_->files) CloseHandle(file); + open_->files.clear(); +} #else struct GameArchives::Open {}; void GameArchives::forget(const fs::path&) const {} +void GameArchives::close_all() const {} #endif void GameArchives::keep_open() { if (!open_) open_ = std::make_shared(); } diff --git a/Engine/Vfs/game_archives.h b/Engine/Vfs/game_archives.h index cdd6b330..43a21cdb 100644 --- a/Engine/Vfs/game_archives.h +++ b/Engine/Vfs/game_archives.h @@ -71,6 +71,9 @@ public: void keep_open(); // Closes an archive kept open, before the file is replaced by another. void forget(const std::filesystem::path& file) const; + // Closes every archive kept open; a later read opens its file again. For one kept between + // jobs, so the files are not held while nothing reads them (a mod could not be updated). + void close_all() const; private: struct Open; // the archives kept open (keep_open); null: each read opens its file diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index cde2bc3e..cc196f57 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -3,6 +3,7 @@ #include "builtin_packs.h" #include "mod_merge.h" +#include "mod_conflicts.h" #include "native_db.h" #include "Engine/Core/Json/json.h" #include "Engine/Game/Build/supported_build.h" @@ -147,7 +148,6 @@ std::vector deck_mod_folders() { 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, @@ -322,6 +322,9 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver // when there is none, so the content loads without any mod installed. std::vector builtin_notes; const auto builtin = unpack_builtin_packs(data_root / mods_folder, builtin_notes); + // Official mods kept where they were before they were mods like any other go into the Mods + // folder first (builtin_packs.h), should the launcher not have moved them yet. + for (auto& note : move_official_mods(data_root / mods_folder)) builtin_notes.push_back("Official mods: " + std::move(note)); auto list = scan_mods(data_root); for (auto& note : builtin_notes) list.notes.push_back(std::move(note)); result.root = std::move(list.root); @@ -337,19 +340,16 @@ 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 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. + // A mod retired with an official one (builtin_packs.h) is left out, should the launcher not + // have taken it away yet: the official one replaces it. std::erase_if(list.entries, [&](const ModEntry& entry) { 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"); + result.notes.push_back(entry.mod.name + " is retired with an official mod; it is left out"); return true; }); - // After every installed mod, so none of theirs is ever shadowed by it; always on. Official - // mods (builtin_packs.h) the same way, those installed and not turned off. + // After every installed mod, so none of theirs is ever shadowed by it; always on. 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. @@ -533,7 +533,7 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver // without it. Removing one mod cannot break another, but a few rounds // are allowed in case two damaged mods hid each other's problems. for (int round = 0;; ++round) { - // The first looks for conflicts before building anything (find_conflicts). + // The first looks for conflicts before building anything (ConflictChecker). merge = merge_mods(result, observe, {.conflicts_first = round == 0}); if (!merge.issue.empty() || round == 4) break; // a fifth merge only rebuilds without round four's removals // (What the check before the merge noted, which the merge's own notes below would not carry.) @@ -544,35 +544,42 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver // ways that cannot both be in the game, the one lower in the load order, whole (so // neither loads half its changes). Mods are looked at from the top of the load // order down, so one that lost only to a mod that is itself left out stays in. - // ReSkate's own packs, which load below every mod, are never left out for this. - const auto official = official_packs(data_root / mods_folder, true); + // ReSkate's own packs, which load below every mod, and official mods, which features + // of ReSkate's need, are never left out for this. const auto ours = [&](const std::string& name) { - return name.starts_with("reskate-builtin-") || name.find('#') != std::string::npos || - std::ranges::any_of(official, [&](const BuiltinPack& pack) { return pack.name == name; }); + if (name.starts_with("reskate-builtin-") || name.find('#') != std::string::npos) return true; + const auto mod = std::ranges::find_if(result.mods, [&](const Mod& each) { return each.name == name; }); + return mod != result.mods.end() && official_mod(mod->directory); }; struct Out { std::vector problems; std::string winner; // left out for a conflict with this mod }; std::map out; - std::set leaving; + std::set> broken; + std::vector order; for (std::size_t i = 0; i < result.mods.size(); ++i) { const auto& name = result.mods[i].name; + order.push_back(name); if (const auto found = merge.problems.find(name); found != merge.problems.end() && !found->second.empty()) { out[i].problems = found->second; - leaving.insert(name); - continue; + broken.insert(name); } - if (ours(name)) continue; - for (const auto& row : merge.conflicts) { - if (row.lost != name || leaving.contains(row.kept)) continue; - auto& entry = out[i]; - if (entry.winner.empty()) entry.winner = row.kept; - entry.problems.push_back("conflicts with " + row.kept + ": both change " + row.asset + - (row.assets > 1 ? " and " + std::to_string(row.assets - 1) + " other asset(s)" : std::string{}) + - ", and " + row.kept + " is higher in the load order"); + } + const auto losses = conflict_losses(order, merge.conflicts, broken, ours); + for (std::size_t i = 0; i < result.mods.size(); ++i) { + const auto loss = losses.find(result.mods[i].name); + if (loss == losses.end()) continue; + auto& entry = out[i]; + entry.winner = loss->second.winner; + for (const auto* row : loss->second.rows) { + std::string areas; + for (const auto& area : row->areas) areas += (areas.empty() ? "" : ", ") + area; + entry.problems.push_back("conflicts with " + row->kept + ": both change " + + (areas.empty() ? std::string("the same game files") : areas) + " (" + row->asset + + (row->assets > 1 ? " and " + std::to_string(row->assets - 1) + " more" : std::string{}) + + "), and " + row->kept + " is higher in the load order"); } - if (out.contains(i)) leaving.insert(name); } const bool removed = !out.empty(); if (removed) diff --git a/Engine/Vfs/mod_conflicts.h b/Engine/Vfs/mod_conflicts.h new file mode 100644 index 00000000..a6df54ac --- /dev/null +++ b/Engine/Vfs/mod_conflicts.h @@ -0,0 +1,70 @@ +#pragma once + +#include "mod_catalog.h" +#include "mod_merge.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::mods { + +// Which enabled mods conflict (MergeReport::conflicts): two mods that change one of the game's EBX +// in ways that cannot be combined, or one of its Lua scripts differently. Worked out from the +// mods' asset lists without merging, by the rule the merge itself applies. What it reads of each +// mod and of the game is kept, so a check after a mod was moved, turned on or turned off reads +// only what is new: the launcher's mod list checks after every change. A mod whose files changed +// is read again. No file is held open between checks. One check at a time. +class ConflictChecker { +public: + // `data_root`: the game folder, holding Data and Mods. + explicit ConflictChecker(std::filesystem::path data_root); + ~ConflictChecker(); + ConflictChecker(const ConflictChecker&) = delete; + ConflictChecker& operator=(const ConflictChecker&) = delete; + + // `mods`: the enabled ones, highest priority first. On up to `threads` threads beside the + // calling one (`background`: under normal priority). Never throws: what cannot be read is + // left out, as the merge's own check catches it. + [[nodiscard]] std::vector check(const std::vector& mods, std::size_t threads = 0, + bool background = false) noexcept; + + struct State; // what has been read (mod_merge_conflicts.cpp) + +private: + std::unique_ptr state_; +}; + +// A mod the launch leaves out for conflicts: the first mod it lost to, and every row it lost. +struct ConflictLoss { + std::string winner; + std::vector rows; // pointing into the conflicts given +}; +// Which of the mods in `order` (folder names, highest priority first) the launch leaves out for +// `conflicts`: from the top down, a mod that lost to one still loading. One that lost only to a +// mod itself left out stays in. `leaving`: mods left out already, for other reasons; `exempt`: +// mods never left out for this (ReSkate's own packs). By folder name. +std::map> conflict_losses(const std::vector& order, + const std::vector& conflicts, const std::set>& leaving = {}, + const std::function& exempt = {}); + +// An EBX type that is only how something looks: an appearance preset (a colourway), a texture, a +// mesh. Two mods' versions of one are no conflict: the higher mod's is kept, and both mods still +// work, the lower one with the other's look on that one item. +bool content_type(std::string_view root_type); + +// The mod a mod left out of the launch lost a conflict to (its first problem names it, as the +// catalog writes it), or empty when it was left out for something else. +std::string conflict_winner(const Mod& mod); + +// What part of the game an asset is, in a player's words, by where it is: "menus", "skater +// setup", "scripts", "items"... +std::string asset_area(std::string_view asset); + +} // namespace dingosdk::mods diff --git a/Engine/Vfs/mod_item_sources.h b/Engine/Vfs/mod_item_sources.h index 05cccf74..5937feaf 100644 --- a/Engine/Vfs/mod_item_sources.h +++ b/Engine/Vfs/mod_item_sources.h @@ -14,7 +14,7 @@ inline constexpr char item_sources_file[] = "reskate-items.json"; struct ItemSource { std::string mod; // its folder name std::string title, author, version; // as its manifest gives them - std::string folder; // where it is, from Mods/ ("x" or ".reskate-official/x"), with its icon.png + std::string folder; // where it is, from Mods/ ("x"), with its icon.png std::vector keys; // the items it adds or changes, lower case }; diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index b9a28268..128be11d 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -408,6 +408,40 @@ ModList scan_mods(const std::filesystem::path& data_root) noexcept { return result; } +bool mod_disabled(const std::filesystem::path& mods_root, std::string_view name) noexcept { + try { + std::error_code error; + const auto path = mods_root / order_file; + if (!fs::is_regular_file(path, error)) return false; + for (const auto& [listed, enabled] : read_order(path)) + if (lower(listed) == lower(name)) return !enabled; + } catch (...) {} + return false; +} + +bool set_mod_enabled(const std::filesystem::path& mods_root, std::string_view name, bool enabled) noexcept { + try { + if (!valid_mod_name(name)) return false; + std::error_code error; + const auto path = mods_root / order_file; + auto order = fs::is_regular_file(path, error) ? read_order(path) : std::vector>{}; + const auto found = std::ranges::find_if(order, [&](const auto& row) { return lower(row.first) == lower(name); }); + if (found != order.end()) found->second = enabled; + else order.emplace_back(std::string(name), enabled); + std::vector entries; + for (const auto& [listed, on] : order) { + ModEntry entry; + entry.mod.name = listed; + entry.enabled = on; + entries.push_back(std::move(entry)); + } + save_mod_order(mods_root, entries); + return true; + } catch (...) { + return false; + } +} + void save_mod_order(const std::filesystem::path& mods_root, const std::vector& entries) { auto rows = Json::array(); for (const auto& entry : entries) { diff --git a/Engine/Vfs/mod_list.h b/Engine/Vfs/mod_list.h index 2ccdf9de..ffa9121f 100644 --- a/Engine/Vfs/mod_list.h +++ b/Engine/Vfs/mod_list.h @@ -122,4 +122,12 @@ Mod describe_mod(const std::filesystem::path& directory, std::string name, std:: // Throws with a readable message on failure. void save_mod_order(const std::filesystem::path& mods_root, const std::vector& entries); +// Whether mods.json lists the mod folder `name` switched off. A folder it does not list loads. +// Reads mods.json alone, not the mods. Never throws: false when it cannot be read. +bool mod_disabled(const std::filesystem::path& mods_root, std::string_view name) noexcept; +// Switches the mod folder `name` on or off in mods.json, every other row as it was; one it does +// not list yet is added at its end, the bottom of the load order. False when mods.json could not +// be read (malformed: left for the player to see) or written. Never throws. +bool set_mod_enabled(const std::filesystem::path& mods_root, std::string_view name, bool enabled) noexcept; + } // namespace dingosdk::mods diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 3008ae75..6899fc86 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -4,6 +4,7 @@ #include "content_catalogs.h" #include "mod_item_sources.h" #include "mod_merge_internal.h" +#include "mod_conflicts.h" #include "mod_store_copies.h" #include "native_db.h" #include "Engine/Core/Platform/path_text.h" @@ -131,7 +132,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (options.conflicts_first && !options.live) { checking("Checking the mods for conflicts"); const auto started = Clock::now(); - report.conflicts = find_conflicts(mods, modFiles, catalog.data_root, output, readers - 1); + report.conflicts = ConflictChecker(catalog.data_root).check(mods, readers - 1); const auto tenths = (std::chrono::duration_cast(Clock::now() - started).count() + 50) / 100; report.notes.push_back("Conflict check: " + std::to_string(report.conflicts.size()) + " conflicting pair(s) in " + std::to_string(tenths / 10) + '.' + std::to_string(tenths % 10) + " s"); @@ -444,6 +445,11 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con // after another in the order they always were, which puts every byte where merging // them in turn would have. A merge while the game runs uses fewer threads, at a lower // priority: the game is using the others, and a player is waiting on this one. + // What reading each mod's bundles found before, while its files are as they were (ScanCache). + std::map> fingerprints; + for (const auto* mod : mods) fingerprints.emplace(mod->name, mod_fingerprint(mod->directory)); + for (const auto& mod : catalog.inactive) fingerprints.emplace(mod.name, mod_fingerprint(mod.directory)); + ScanCache scans(catalog.root / scan_cache_file, std::move(fingerprints)); struct Job { std::string relative; std::optional store; // waiting: what the merge wrote @@ -483,7 +489,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con sources.push_back(std::move(source)); } job.merged = combine(baseRoot / fs::path(relative), baseRoot, sources, job.report, - relative, job.used, *job.store, manifestArchive, gameRoot, grid, overrides); + relative, job.used, *job.store, manifestArchive, gameRoot, grid, overrides, scans); if (job.store->holding()) for (std::size_t index = 0; index < job.merged.bundles.size(); ++index) { auto region = fb::read_bundle_region(job.merged.bundles[index].region); @@ -569,8 +575,13 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con for (auto& row : job.report.conflicts) { const auto same = std::ranges::find_if(report.conflicts, [&](const MergeReport::Conflict& held) { return held.kept == row.kept && held.lost == row.lost; }); - if (same != report.conflicts.end()) same->assets += row.assets; - else report.conflicts.push_back(std::move(row)); + if (same == report.conflicts.end()) { + report.conflicts.push_back(std::move(row)); + continue; + } + same->assets += row.assets; + for (auto& area : row.areas) + if (std::ranges::find(same->areas, area) == same->areas.end()) same->areas.push_back(std::move(area)); } auto bytes = fb::write_patch_toc(job.merged.bundles, job.merged.chunks, job.merged.flags); // Read the result back before publishing it: a TOC whose perfect @@ -601,6 +612,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con write_placements(placementsPath, record); write_map_meshes(output / map_meshes_file, report.map_meshes); } + scans.save(); lap("superbundles"); // The layout lists every superbundle the merged layer now provides. diff --git a/Engine/Vfs/mod_merge.h b/Engine/Vfs/mod_merge.h index 229e38a5..1b10fa27 100644 --- a/Engine/Vfs/mod_merge.h +++ b/Engine/Vfs/mod_merge.h @@ -36,6 +36,7 @@ struct MergeReport { std::string lost; std::string asset; std::size_t assets{}; + std::vector areas; // what those assets are, in a player's words (asset_area) }; std::vector conflicts; // Nothing was built: the check before the merge found `conflicts` (MergeOptions::conflicts_first). @@ -92,7 +93,7 @@ struct MergeOptions { // the others against it. Reading every mod's items and meshes again is most of what // adding one map would otherwise wait on. std::vector checked; - // Look for mods that conflict (find_conflicts) before building anything, and when some do, + // Look for mods that conflict (ConflictChecker, mod_conflicts.h) before building anything, and when some do, // stop there (MergeReport::stopped_for_conflicts) so the caller merges once without them. bool conflicts_first{}; }; diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 4bfe2bfa..319f401b 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -1,4 +1,5 @@ #include "mod_merge_internal.h" +#include "mod_conflicts.h" #include "Engine/Resource/cas_codec.h" #include "Engine/Resource/ebx_document.h" #include "Engine/Resource/ebx_fixup_repair.h" @@ -121,14 +122,10 @@ struct BundleState { // The base's own sha1 for each asset, so a mod that merely carries an // unchanged copy can be told apart from one that changed it. std::unordered_map baseSha; - // Of each asset, the last version a mod shipped as its own change (not the game's copy, not - // one rewritten to carry another mod's change), and every mod that shipped that same version. - // A later mod shipping its own different version means theirs is lost: a conflict. - struct OwnChange { - fb::Sha1 sha1; - std::vector mods; - }; - std::unordered_map ownChange; + // Of each Lua script, every version a mod shipped as its own change (not the game's copy, not + // one rewritten to carry another mod's change), and which mod: a later mod shipping another + // version means each of those is lost, a conflict. + std::unordered_map>> ownChange; // Assets a mod added that a higher-priority mod's different asset of the // same name then replaced: the key, and the sha1 of the copy that went. std::vector> displaced; @@ -234,12 +231,10 @@ std::span unsigned_bytes(std::span data) { // `lost`'s change to `asset` is not in the game, `kept`'s copy being there (MergeReport::conflicts). void conflict(MergeReport& report, const std::string& kept, const std::string& lost, const std::string& asset) { if (kept.empty() || lost.empty() || kept == lost) return; - for (auto& row : report.conflicts) - if (row.kept == kept && row.lost == lost) { - ++row.assets; - return; - } - report.conflicts.push_back({kept, lost, asset, 1}); + auto row = std::ranges::find_if(report.conflicts, [&](const MergeReport::Conflict& held) { return held.kept == kept && held.lost == lost; }); + if (row == report.conflicts.end()) row = report.conflicts.insert(report.conflicts.end(), {kept, lost, asset, 0}); + ++row->assets; + if (auto area = asset_area(asset); std::ranges::find(row->areas, area) == row->areas.end()) row->areas.push_back(std::move(area)); } } // namespace @@ -354,7 +349,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::vector& sources, MergeReport& report, const std::string& relative, ArchiveUse& used, CasStore& store, std::uint16_t manifestArchive, const fs::path& gameRoot, const GridPlan& grid, - const AssetOverrides& overrides) { + const AssetOverrides& overrides, ScanCache& scans) { fb::TocDocument merged; merged.flags = 3; @@ -609,26 +604,44 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, auto manifest = casBacked ? std::move(casManifest) : fb::read_binary_bundle(region.inlineManifest); std::size_t removed{}; std::string example; - for (std::size_t index = 0; index < manifest.ebx.size(); ++index) { - const auto at = (casBacked ? 1 : 0) + index; - if (at >= region.files.size() || overridden.contains(at)) continue; + // What reading this copy found last time, while the mod's files are as they were + // (ScanCache): then only what it found is read again. Else all of it, and what it finds + // is kept for the next merge, unless something could not be read. + const auto scanKey = lower(relative) + '|' + key; + const auto known = scans.find(modName, scanKey); + ScanCache::Bundle found; + bool whole = true; + const std::size_t listed = casBacked ? 1 : 0; // the region file of the first EBX + const auto check = [&](std::size_t index) { + const auto at = listed + index; + if (index >= manifest.ebx.size() || at >= region.files.size()) return; const auto& file = region.files[at]; - if (!file.location.patch) continue; // the game's own copy + if (!file.location.patch) return; // the game's own copy auto& asset = manifest.ebx[index]; try { auto encoded = store.read(root, file.location, file.offset, file.size); + auto checked = asset; + const auto count = repair_ebx_payload(encoded, checked, gameRoot); + if (!count) return; + found.repair.push_back(static_cast(at)); + // (One that carries another mod's change is that change, not this copy.) + if (overridden.contains(at)) return; const auto before = asset.sha1; - const auto count = repair_ebx_payload(encoded, asset, gameRoot); - if (!count) continue; + asset = checked; repaired[at] = store.write(file.location.installChunk, manifestArchive, encoded); repairedFrom.emplace(at, std::pair{file, before}); removed += count; if (example.empty()) example = asset.name; } catch (const std::exception& error) { + whole = false; report.notes.push_back(modName + ": " + bundle.name + ": " + asset.name + ": its type references could not be checked (" + error.what() + ")"); } - } + }; + if (known) + for (const auto at : known->repair) check(at - listed); + else + for (std::size_t index = 0; index < manifest.ebx.size(); ++index) check(index); const auto ebxRepaired = repaired.size(); // A map's level bundle: its own meshes, every level of detail, for the mesh streaming // pool the runtime gives the map (reskate-map-meshes.json). Each payload once. @@ -636,7 +649,12 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, key.compare(levels.size(), 19, "dingolevel_reskate_") == 0) { const auto folder = key.substr(levels.size(), key.find('/', levels.size()) - levels.size()); const auto resourcesAt = (casBacked ? 1 : 0) + manifest.ebx.size(); - for (std::size_t index = 0; index < manifest.resources.size(); ++index) { + if (known) { + for (const auto& mesh : known->meshes) + if (countedMeshes.emplace(root.string(), mesh.chunk, mesh.archive, mesh.offset).second) + report.map_meshes[folder] += mesh.bytes; + } + for (std::size_t index = 0; !known && index < manifest.resources.size(); ++index) { if (manifest.resources[index].resourceType != mesh_set_type || resourcesAt + index >= region.files.size()) continue; const auto& file = region.files[resourcesAt + index]; if (!file.location.patch || @@ -652,16 +670,22 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, bytes += std::uint64_t{geometry.vertex_bytes} + geometry.index_bytes; } report.map_meshes[folder] += bytes; - } catch (const std::exception&) {} + found.meshes.push_back({file.location.installChunk, file.location.archive, file.offset, bytes}); + } catch (const std::exception&) { + whole = false; + } } } // Its textures that never stream (mod_merge_textures.cpp): the changed headers go into // the merged patch; the chunk is still the mod's, its bundle file narrowed to the tail. // A map's own level bundles only (Studio names them after the level). if (tocChunks && key.starts_with("win32/levels/game/dingolevel_reskate_")) { - auto streamed = stream_resident_textures(store, root, manifest, region.files, casBacked ? 1 : 0, *tocChunks, gameRoot); - for (auto& [at, encoded] : streamed.headers) + auto streamed = stream_resident_textures(store, root, manifest, region.files, casBacked ? 1 : 0, *tocChunks, gameRoot, + known ? &known->textures : nullptr); + for (auto& [at, encoded] : streamed.headers) { repaired[at] = store.write(region.files[at].location.installChunk, manifestArchive, encoded); + found.textures.push_back(static_cast(at)); + } if (!streamed.headers.empty()) report.notes.push_back(modName + ": " + bundle.name + ": " + std::to_string(streamed.headers.size()) + " texture(s) kept every mip loaded; they stream now, " + std::to_string(streamed.bytes >> 20) + @@ -669,6 +693,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, } if (casBacked) casManifest = std::move(manifest); else if (!repaired.empty()) region.inlineManifest = fb::write_binary_bundle(manifest); + if (!known && whole) scans.keep(modName, scanKey, std::move(found)); if (ebxRepaired) report.notes.push_back(modName + ": " + bundle.name + ": " + std::to_string(ebxRepaired) + " asset(s) listed " + std::to_string(removed) + " type reference(s) on another fixup's slot, " @@ -842,25 +867,20 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, const auto* owner = isBase ? nullptr : &*owners.insert(modName).first; // By the mod whose added assets this copy's replace: how many, and one of them. std::map> replaced; - // This mod's own version of an asset the merge does not combine (EBX the game has is combined - // further down, and so are the shader tables and the preset table): a mod lower in the load - // order whose own version differs loses it, a conflict. find_conflicts applies the same rule - // before the merge. + // This mod's own version of one of the game's Lua scripts: a mod lower in the load order whose + // own version differs loses it, a conflict, as the two cannot both run. (EBX the game has is + // combined further down, a conflict only where that cannot be done. Anything else two mods + // both change or add under one name is content, a picture or a mesh: the higher mod's is + // kept and both mods load, as each still works.) ConflictChecker applies the same rule before + // the merge. const auto own_change = [&](const std::string& key, const Asset& asset, bool rewritten) { - if (isBase || rewritten) return; - if (asset.asset.kind == fb::AssetKind::resource && - (asset.asset.resourceType == fb::shader::programLookupType || - asset.asset.resourceType == fb::shader::textureLookupType || fb::bundle_ref::is_table(asset.asset.name))) - return; + if (isBase || rewritten || asset.asset.kind != fb::AssetKind::resource || asset.asset.resourceType != luaScriptResourceType) return; const auto shipped = state.baseSha.find(key); - if (shipped != state.baseSha.end() && (asset.asset.kind == fb::AssetKind::ebx || shipped->second == asset.asset.sha1)) return; - auto& last = state.ownChange[key]; - if (!last.mods.empty() && last.sha1 == asset.asset.sha1) { - if (std::ranges::find(last.mods, modName) == last.mods.end()) last.mods.push_back(modName); - return; - } - for (const auto& lost : last.mods) conflict(report, modName, lost, asset.asset.name); - last = {asset.asset.sha1, {modName}}; + if (shipped == state.baseSha.end() || shipped->second == asset.asset.sha1) return; + auto& earlier = state.ownChange[key]; + for (const auto& [sha1, lost] : earlier) + if (sha1 != asset.asset.sha1) conflict(report, modName, lost, asset.asset.name); + earlier.emplace_back(asset.asset.sha1, modName); }; for (std::size_t position = 0; position < flat.size(); ++position) { auto& entry = flat[position]; @@ -997,6 +1017,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, // what its entries name has to be in the bundle either way. const auto named = entry.asset.kind == fb::AssetKind::ebx && item_list_name(entry.asset.name); if (edits.size() < 2 && !(named && edits.size() == 1)) continue; + // How an item looks is content: the copy kept whole is no conflict (content_type). + bool content{}; try { const auto baseBytes = decode(*baseCopy); std::vector> editBytes; @@ -1051,6 +1073,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, what += ", " + std::to_string(table.conflicts) + " disagreed"; } else { const auto baseDocument = fb::ebx::read_document(baseBytes); + content = content_type(baseDocument.rootType); const auto itemList = lower(baseDocument.rootType) == item_list_type; if (edits.size() < 2 && !itemList) continue; // A material grid is a square interaction matrix whose rows are @@ -1108,7 +1131,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, if (entry.mod) others.erase(*entry.mod); std::string names; for (const auto& other : others) names += (names.empty() ? "" : ", ") + other; - if (entry.mod) + if (entry.mod && !content) for (const auto& other : others) conflict(report, *entry.mod, other, entry.asset.name); if (!others.empty()) report.notes.push_back(entry.asset.name + ": kept " + @@ -1171,7 +1194,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, report.notes.push_back(entry.asset.name + ": kept the highest-priority copy, the edits could not be combined (" + failure.what() + ")"); - if (entry.mod) + if (entry.mod && !content) for (const auto& contribution : found->second) if (!contribution.base && contribution.sha1 != baseCopy->sha1 && contribution.sha1 != entry.asset.sha1) conflict(report, *entry.mod, path_utf8(contribution.root->filename()), entry.asset.name); diff --git a/Engine/Vfs/mod_merge_conflicts.cpp b/Engine/Vfs/mod_merge_conflicts.cpp index df7c5a0a..897d44dd 100644 --- a/Engine/Vfs/mod_merge_conflicts.cpp +++ b/Engine/Vfs/mod_merge_conflicts.cpp @@ -1,20 +1,20 @@ +#include "mod_conflicts.h" #include "mod_merge_internal.h" -#include "Engine/Resource/bundle_ref_table.h" #include "Engine/Resource/ebx_document.h" #include "Engine/Resource/ebx_merge.h" -#include "Engine/Resource/shader_lookup.h" #include #include #include #include -#include +#include #include #include #include -namespace dingosdk::mods::detail { +namespace dingosdk::mods { +using namespace detail; namespace { // The merge keys an asset within a bundle by its kind and, for a chunk its guid, for the rest its @@ -29,44 +29,29 @@ std::uint64_t key_hash(const fb::BundleAsset& asset) { return value ^ (static_cast(asset.kind) + 1) * 0x9E3779B97F4A7C15ULL; } -// Assets the merge combines whatever the mods did to them, so two versions are no conflict: -// the shader tables, the preset table and the collision the material grid renumbers. -bool combined_anyway(const fb::BundleAsset& asset) { - return asset.kind == fb::AssetKind::resource && - (asset.resourceType == fb::shader::programLookupType || asset.resourceType == fb::shader::textureLookupType || - asset.resourceType == fb::material_grid::physicsResourceType || fb::bundle_ref::is_table(asset.name)); +// The assets two mods' versions of can conflict: EBX, and Lua scripts. The rest is content, of +// which the higher mod's version is kept and both mods still work (decide). +bool can_conflict(const fb::BundleAsset& asset) { + return asset.kind == fb::AssetKind::ebx || + (asset.kind == fb::AssetKind::resource && asset.resourceType == luaScriptResourceType); } using Rows = std::vector; void conflict(Rows& rows, const std::string& kept, const std::string& lost, const std::string& asset) { if (kept.empty() || lost.empty() || kept == lost) return; - for (auto& row : rows) - if (row.kept == kept && row.lost == lost) { - ++row.assets; - return; - } - rows.push_back({kept, lost, asset, 1}); + auto row = std::ranges::find_if(rows, [&](const MergeReport::Conflict& held) { return held.kept == kept && held.lost == lost; }); + if (row == rows.end()) row = rows.insert(rows.end(), {kept, lost, asset, 0}); + ++row->assets; + if (auto area = asset_area(asset); std::ranges::find(row->areas, area) == row->areas.end()) row->areas.push_back(std::move(area)); } // One mod's version of an asset in a bundle that is not the game's. struct Change { std::uint64_t key{}; - std::size_t copy{}; // in Shared::copies std::size_t at{}; // the asset's place in the manifest's EBX, for reading it const fb::BundleAsset* asset{}; }; -// One bundle that two or more mods ship in one superbundle, and what is read of it. -struct Shared { - const fb::TocBundle* game{}; - std::vector> copies; // (mod, its copy), lowest priority first - std::optional gameListing; - // The game's version of each asset: its sha1, and its place among the EBX. - std::unordered_map> original; - std::vector> listings; // by copy - std::vector> changes; // by copy: what it ships that is not the game's -}; - // `count` pieces of work on up to `threads` threads beside this one, taken in turn. void in_parallel(std::size_t count, std::size_t threads, bool background, const std::function& work) { std::atomic next{0}; @@ -91,190 +76,345 @@ void in_parallel(std::size_t count, std::size_t threads, bool background, const run(); } -// The game's copy of a bundle, keyed, to tell a mod's change from a copy of the game's. -void read_game(Shared& shared, const CasStore& store, const fs::path& baseRoot, const fs::path& gameRoot) { - if (!shared.game) return; - shared.gameListing = list_bundle(store, baseRoot, baseRoot, *shared.game, gameRoot); - if (!shared.gameListing) return; - const auto& manifest = shared.gameListing->manifest; - for (std::size_t index = 0; index < manifest.ebx.size(); ++index) - shared.original.emplace(key_hash(manifest.ebx[index]), std::pair{manifest.ebx[index].sha1, index}); - for (const auto* list : {&manifest.resources, &manifest.chunks}) - for (const auto& asset : *list) shared.original.emplace(key_hash(asset), std::pair{asset.sha1, std::size_t{}}); -} +} // namespace -// One mod's copy: its asset list, and of that what is not the game's. -void read_copy(Shared& shared, std::size_t copy, const std::vector& mods, const CasStore& store, - const fs::path& baseRoot, const fs::path& gameRoot) { - auto& listing = shared.listings[copy]; - listing = list_bundle(store, mods[shared.copies[copy].first]->directory, baseRoot, *shared.copies[copy].second, gameRoot); - if (!listing) return; // (the merge notes what it cannot read) - auto& changes = shared.changes[copy]; - const auto take = [&](const std::vector& list, bool ebx) { - for (std::size_t index = 0; index < list.size(); ++index) { - const auto& asset = list[index]; - if (combined_anyway(asset)) continue; - const auto key = key_hash(asset); - if (const auto shipped = shared.original.find(key); shipped != shared.original.end() && shipped->second.first == asset.sha1) - continue; - changes.push_back({key, copy, ebx ? index : 0, &asset}); - } +// Everything read so far, kept between checks. Map nodes stay where they are, so what points into +// them (bundles into their TOC, changes into their copy's asset list) stays good. +struct ConflictChecker::State { + fs::path baseRoot, gameRoot, output; + std::optional layout; + std::optional store; + + struct Toc { + bool read{}; + std::optional document; + std::map> bundles; // by lower-case name }; - take(listing->manifest.ebx, true); - take(listing->manifest.resources, false); - take(listing->manifest.chunks, false); -} + // The game's copy of a bundle: its asset list, and its version of each asset that can + // conflict (sha1, place among the EBX). + struct GameBundle { + bool read{}; + std::optional listing; + std::unordered_map> original; + }; + // A mod's copy of a bundle: its asset list, and of that what can conflict and is not the game's. + struct Copy { + bool read{}; + std::optional listing; + std::vector changes; + }; + struct ModState { + std::string fingerprint; + RelativeFiles files; + std::map> tocs; // by lower-case superbundle + std::map> copies; // by "superbundle|bundle" + }; + std::map> gameTocs; + std::map> gameBundles; // by "superbundle|bundle" + std::map> mods; // by folder + // Whether the edits to an EBX, in that order, could not be combined: by the bundle, the asset + // and each edit's sha1. Read again for each check that meets them otherwise. + std::mutex verdictMutex; + std::unordered_map verdicts; + + static void read_toc(Toc& toc, const fs::path& path) { + toc.read = true; + std::error_code error; + if (!fs::is_regular_file(path, error)) return; + toc.document = fb::read_toc(read_file(path)); + for (const auto& bundle : toc.document->bundles) toc.bundles.emplace(lower(bundle.name), &bundle); + } +}; + +namespace { +// One bundle that two or more of the checked mods ship in one superbundle. +struct Shared { + std::string key; // "superbundle|bundle" + ConflictChecker::State* state{}; + const ConflictChecker::State::GameBundle* game{}; + std::vector> copies; // (mod, its copy), lowest priority first +}; // The conflicts in one bundle, from the changes its copies were found to ship. -Rows decide(const Shared& shared, const std::vector& mods, const CasStore& store, - const fs::path& baseRoot, const fs::path& gameRoot) { +Rows decide(const Shared& shared, const std::vector& mods, ConflictChecker::State& state) { Rows rows; + if (!shared.game) return rows; + struct Seen { + std::size_t copy{}; + const Change* change{}; + }; // Each asset's changes, lowest priority first, the assets in the order they were first met. - std::unordered_map> byAsset; + std::unordered_map> byAsset; std::vector order; - for (const auto& copy : shared.changes) - for (const auto& change : copy) { + for (std::size_t copy = 0; copy < shared.copies.size(); ++copy) + for (const auto& change : shared.copies[copy].second->changes) { auto& list = byAsset[change.key]; if (list.empty()) order.push_back(change.key); - list.push_back(&change); + list.push_back({copy, &change}); } - const auto mod_of = [&](const Change& change) -> const std::string& { return mods[shared.copies[change.copy].first]->name; }; + const auto mod_of = [&](const Seen& seen) -> const std::string& { return mods[shared.copies[seen.copy].first]->name; }; for (const auto key : order) { const auto& changes = byAsset.at(key); - const auto& kept = *changes.back(); + const auto& kept = changes.back(); // Two mods' different changes, or nothing to decide. - if (std::ranges::all_of(changes, [&](const Change* change) { - return change->asset->sha1 == kept.asset->sha1 || shared.copies[change->copy].first == shared.copies[kept.copy].first; })) + if (std::ranges::all_of(changes, [&](const Seen& seen) { + return seen.change->asset->sha1 == kept.change->asset->sha1 || mod_of(seen) == mod_of(kept); })) continue; - const auto shipped = shared.original.find(key); + // What two mods add under one name is content: the higher mod's is kept and both load. + const auto shipped = shared.game->original.find(key); + if (shipped == shared.game->original.end()) continue; - if (kept.asset->kind != fb::AssetKind::ebx || shipped == shared.original.end()) { - // Not combined: of each asset the highest-priority mod's version is the one in the game, and a - // mod lower down that changed it differently loses its change. (The rule of absorb's own_change.) - fb::Sha1 last{}; - std::vector holders; - for (const auto* change : changes) { - const auto& name = mod_of(*change); - if (!holders.empty() && change->asset->sha1 == last) { - if (std::ranges::find(holders, &name) == holders.end()) holders.push_back(&name); - continue; - } - for (const auto* lost : holders) conflict(rows, name, *lost, change->asset->name); - last = change->asset->sha1; - holders = {&name}; + if (kept.change->asset->kind != fb::AssetKind::ebx) { + // One of the game's Lua scripts: only the highest-priority mod's version runs, so every + // mod lower down that changed it differently loses to each mod above it with another + // version (which of those it loses to depends on which of them load). (The rule of + // absorb's own_change in mod_merge_combine.cpp.) + std::vector earlier; + for (const auto& seen : changes) { + for (const auto* lower : earlier) + if (lower->change->asset->sha1 != seen.change->asset->sha1) + conflict(rows, mod_of(seen), mod_of(*lower), seen.change->asset->name); + earlier.push_back(&seen); } continue; } // EBX the game has is combined, the changes of every mod onto the game's copy: a conflict only // when that cannot be done (the kept-whole and could-not-combine cases of the merge's combine). - const auto lose_to_kept = [&] { - for (const auto* change : changes) - if (change->asset->sha1 != kept.asset->sha1) conflict(rows, mod_of(kept), mod_of(*change), kept.asset->name); - }; - try { - const auto base = fb::ebx::read_document( - read_asset(store, baseRoot, baseRoot, *shared.gameListing, shipped->second.second, gameRoot)); - // The combined material grid is worked out separately (mod_merge_grid.cpp). - if (lower(base.rootType).find("materialgrid") != std::string::npos) continue; - std::map documents; - std::vector edits; - for (const auto* change : changes) { - auto [at, fresh] = documents.try_emplace(change->asset->sha1); - if (fresh) - at->second = fb::ebx::read_document(read_asset(store, mods[shared.copies[change->copy].first]->directory, - baseRoot, *shared.listings[change->copy], change->at, gameRoot)); - edits.push_back(&at->second); - } - fb::ebx::MergeSummary summary; - (void)fb::ebx::merge_documents(base, edits, &summary); - if (summary.dropped.empty() && !summary.instances && !summary.arrayEntries && (!summary.values || !summary.exact())) - lose_to_kept(); - } catch (const std::exception&) { - lose_to_kept(); + std::string verdictKey = shared.key + '|' + std::to_string(key); + for (const auto& seen : changes) + verdictKey.append(reinterpret_cast(seen.change->asset->sha1.bytes.data()), seen.change->asset->sha1.bytes.size()); + std::optional uncombinable; + { + std::lock_guard lock(state.verdictMutex); + if (const auto known = state.verdicts.find(verdictKey); known != state.verdicts.end()) uncombinable = known->second; } + if (!uncombinable) { + try { + const auto base = fb::ebx::read_document( + read_asset(*state.store, state.baseRoot, state.baseRoot, *shared.game->listing, shipped->second.second, state.gameRoot)); + // The combined material grid is worked out separately (mod_merge_grid.cpp), and how an item + // looks is content: the higher mod's is kept. + if (lower(base.rootType).find("materialgrid") != std::string::npos || content_type(base.rootType)) { + uncombinable = false; + } else { + std::map documents; + std::vector edits; + for (const auto& seen : changes) { + auto [at, fresh] = documents.try_emplace(seen.change->asset->sha1); + if (fresh) + at->second = fb::ebx::read_document(read_asset(*state.store, mods[shared.copies[seen.copy].first]->directory, + state.baseRoot, *shared.copies[seen.copy].second->listing, seen.change->at, state.gameRoot)); + edits.push_back(&at->second); + } + fb::ebx::MergeSummary summary; + (void)fb::ebx::merge_documents(base, edits, &summary); + uncombinable = summary.dropped.empty() && !summary.instances && !summary.arrayEntries && + (!summary.values || !summary.exact()); + } + } catch (const std::exception&) { + uncombinable = true; + } + std::lock_guard lock(state.verdictMutex); + state.verdicts.emplace(std::move(verdictKey), *uncombinable); + } + if (*uncombinable) + for (const auto& seen : changes) + if (seen.change->asset->sha1 != kept.change->asset->sha1) + conflict(rows, mod_of(kept), mod_of(seen), kept.change->asset->name); } return rows; } - } // namespace -std::vector find_conflicts(const std::vector& mods, - const std::map& modFiles, - const fs::path& dataRoot, const fs::path& output, - std::size_t threads, bool background) { - const auto baseRoot = dataRoot / L"Data"; - const auto gameRoot = dataRoot; - // The superbundles two or more mods ship: only there can two mods' versions of an asset meet. - std::map> shippedBy; // lower case relative path, mods highest priority first - std::map spelled; - for (std::size_t mod = 0; mod < mods.size(); ++mod) - if (const auto files = modFiles.find(mods[mod]); files != modFiles.end()) - for (const auto& relative : files->second.tocs) { +ConflictChecker::ConflictChecker(std::filesystem::path data_root) : state_(std::make_unique()) { + state_->gameRoot = data_root; + state_->baseRoot = data_root / L"Data"; + state_->output = data_root / mods_folder / generated_folder; +} + +ConflictChecker::~ConflictChecker() = default; + +std::vector ConflictChecker::check(const std::vector& mods, std::size_t threads, + bool background) noexcept { + try { + auto& state = *state_; + if (!state.store) { + state.layout = vfs::read_layout(state.baseRoot / L"layout.toc"); + state.store.emplace(state.baseRoot, state.output, state.layout->root); + } + // What was read is kept, the files are not: between checks a mod's archives must be free + // to be replaced (an update, an uninstall). + struct Close { + const CasStore& store; + ~Close() { store.close_all(); } + } close{*state.store}; + // Each mod as read before, unless its files changed since. (Looking at every mod's files + // is most of a check that finds nothing new: on several threads too.) + std::vector held(mods.size()); + for (std::size_t mod = 0; mod < mods.size(); ++mod) held[mod] = &state.mods[mods[mod]->directory.wstring()]; + in_parallel(mods.size(), threads, background, [&](std::size_t mod) { + auto& known = *held[mod]; + const auto fingerprint = mod_fingerprint(mods[mod]->directory); + if (!fingerprint.empty() && known.fingerprint == fingerprint) return; + known = {}; + known.fingerprint = fingerprint; + known.files = scan(mods[mod]->directory); + }); + // The superbundles two or more mods ship: only there can two mods' versions of an asset meet. + std::map> shippedBy; // lower case, mods highest priority first + std::map spelled; + for (std::size_t mod = 0; mod < mods.size(); ++mod) + for (const auto& relative : held[mod]->files.tocs) { auto key = lower(relative); spelled.try_emplace(key, relative); shippedBy[std::move(key)].push_back(mod); } - std::erase_if(shippedBy, [](const auto& entry) { return entry.second.size() < 2; }); - if (shippedBy.empty()) return {}; + std::erase_if(shippedBy, [](const auto& entry) { return entry.second.size() < 2; }); - // Their TOCs, the game's and the mods', kept while the bundles they list are read. - std::vector tocs; - tocs.reserve([&] { - std::size_t count{}; - for (const auto& [relative, by] : shippedBy) count += by.size() + 1; - return count; - }()); - std::vector bundles; - for (const auto& [relative, by] : shippedBy) { - std::map named; - for (auto mod = by.rbegin(); mod != by.rend(); ++mod) { - try { - const auto& toc = tocs.emplace_back(fb::read_toc(read_file(mods[*mod]->directory / fs::path(spelled.at(relative))))); - for (const auto& bundle : toc.bundles) named[lower(bundle.name)].copies.emplace_back(*mod, &bundle); - } catch (const std::exception&) {} // (the merge leaves a mod out whose TOC cannot be read) - } - std::erase_if(named, [](const auto& entry) { return entry.second.copies.size() < 2; }); - if (named.empty()) continue; - std::error_code error; - if (const auto game = baseRoot / fs::path(spelled.at(relative)); fs::is_regular_file(game, error)) try { - const auto& toc = tocs.emplace_back(fb::read_toc(read_file(game))); - for (const auto& bundle : toc.bundles) - if (const auto found = named.find(lower(bundle.name)); found != named.end()) found->second.game = &bundle; - } catch (const std::exception&) {} - for (auto& [name, shared] : named) { - shared.listings.resize(shared.copies.size()); - shared.changes.resize(shared.copies.size()); - bundles.push_back(std::move(shared)); + // The bundles two or more of them ship there, with their copies and the game's. + std::vector bundles; + std::vector> gameUnread; + struct CopyUnread { + State::Copy* copy; + const fs::path* folder; + const fb::TocBundle* bundle; + const State::GameBundle* game; + }; + std::vector copyUnread; + for (const auto& [relative, by] : shippedBy) { + std::map>> named; + for (auto mod = by.rbegin(); mod != by.rend(); ++mod) { + auto& toc = held[*mod]->tocs[relative]; + if (!toc.read) try { + State::read_toc(toc, mods[*mod]->directory / fs::path(spelled.at(relative))); + } catch (const std::exception&) {} // (the merge leaves a mod out whose TOC cannot be read) + for (const auto& [name, bundle] : toc.bundles) named[name].emplace_back(*mod, bundle); + } + std::erase_if(named, [](const auto& entry) { return entry.second.size() < 2; }); + if (named.empty()) continue; + auto& gameToc = state.gameTocs[relative]; + if (!gameToc.read) try { + State::read_toc(gameToc, state.baseRoot / fs::path(spelled.at(relative))); + } catch (const std::exception&) {} + for (const auto& [name, copies] : named) { + Shared shared{relative + '|' + name, &state}; + const auto gameBundle = gameToc.bundles.find(name); + // Without the game's copy, nothing in it can conflict: what mods add is content. + if (gameBundle == gameToc.bundles.end()) continue; + auto& game = state.gameBundles[shared.key]; + if (!game.read) gameUnread.emplace_back(&game, gameBundle->second); + shared.game = &game; + for (const auto& [mod, bundle] : copies) { + auto& copy = held[mod]->copies[shared.key]; + if (!copy.read) copyUnread.push_back({©, &mods[mod]->directory, bundle, &game}); + shared.copies.emplace_back(mod, ©); + } + bundles.push_back(std::move(shared)); + } } + if (bundles.empty()) return {}; + + // On several threads, in three steps, so that one bundle a hundred mods ship is not all one + // thread's: the game's copies not read before, then each mod's copy not read before, then what + // the copies' changes mean in each bundle. The rows are then put together in bundle order. + in_parallel(gameUnread.size(), threads, background, [&](std::size_t index) { + auto& [game, bundle] = gameUnread[index]; + game->read = true; + game->listing = list_bundle(*state.store, state.baseRoot, state.baseRoot, *bundle, state.gameRoot); + if (!game->listing) return; + const auto& manifest = game->listing->manifest; + for (std::size_t at = 0; at < manifest.ebx.size(); ++at) + game->original.emplace(key_hash(manifest.ebx[at]), std::pair{manifest.ebx[at].sha1, at}); + for (const auto& asset : manifest.resources) + if (can_conflict(asset)) game->original.emplace(key_hash(asset), std::pair{asset.sha1, std::size_t{}}); + }); + in_parallel(copyUnread.size(), threads, background, [&](std::size_t index) { + const auto& unread = copyUnread[index]; + auto& copy = *unread.copy; + copy.read = true; + copy.listing = list_bundle(*state.store, *unread.folder, state.baseRoot, *unread.bundle, state.gameRoot); + if (!copy.listing) return; // (the merge notes what it cannot read) + const auto take = [&](const std::vector& list, bool ebx) { + for (std::size_t at = 0; at < list.size(); ++at) { + const auto& asset = list[at]; + if (!can_conflict(asset)) continue; + const auto key = key_hash(asset); + const auto shipped = unread.game->original.find(key); + if (shipped == unread.game->original.end() || shipped->second.first == asset.sha1) continue; + copy.changes.push_back({key, ebx ? at : 0, &asset}); + } + }; + take(copy.listing->manifest.ebx, true); + take(copy.listing->manifest.resources, false); + }); + std::vector found(bundles.size()); + in_parallel(bundles.size(), threads, background, [&](std::size_t index) { + found[index] = decide(bundles[index], mods, state); + }); + Rows rows; + for (const auto& bundle : found) + for (const auto& row : bundle) { + const auto same = std::ranges::find_if(rows, [&](const MergeReport::Conflict& held) { + return held.kept == row.kept && held.lost == row.lost; }); + if (same == rows.end()) { + rows.push_back(row); + continue; + } + same->assets += row.assets; + for (const auto& area : row.areas) + if (std::ranges::find(same->areas, area) == same->areas.end()) same->areas.push_back(area); + } + return rows; + } catch (...) { + return {}; } - if (bundles.empty()) return {}; - - // On several threads, in three steps, so that one bundle a hundred mods ship is not all one - // thread's: the game's copy of each bundle, then each mod's copy of each by itself, then what - // the copies' changes mean in each bundle. The rows are then put together in bundle order. - const auto layout = vfs::read_layout(baseRoot / L"layout.toc"); - const CasStore store(baseRoot, output, layout.root); - in_parallel(bundles.size(), threads, background, [&](std::size_t index) { read_game(bundles[index], store, baseRoot, gameRoot); }); - std::vector> copies; // (bundle, copy) - for (std::size_t index = 0; index < bundles.size(); ++index) - for (std::size_t copy = 0; copy < bundles[index].copies.size(); ++copy) copies.emplace_back(index, copy); - in_parallel(copies.size(), threads, background, [&](std::size_t index) { - read_copy(bundles[copies[index].first], copies[index].second, mods, store, baseRoot, gameRoot); - }); - std::vector found(bundles.size()); - in_parallel(bundles.size(), threads, background, [&](std::size_t index) { - found[index] = decide(bundles[index], mods, store, baseRoot, gameRoot); - }); - Rows rows; - for (const auto& bundle : found) - for (const auto& row : bundle) { - const auto same = std::ranges::find_if(rows, [&](const MergeReport::Conflict& held) { - return held.kept == row.kept && held.lost == row.lost; }); - if (same != rows.end()) same->assets += row.assets; - else rows.push_back(row); - } - return rows; } -} // namespace dingosdk::mods::detail +std::map> conflict_losses(const std::vector& order, + const std::vector& conflicts, const std::set>& leaving, + const std::function& exempt) { + std::map> losses; + auto out = leaving; + for (const auto& name : order) { + if (out.contains(name) || (exempt && exempt(name))) continue; + ConflictLoss loss; + for (const auto& row : conflicts) { + if (row.lost != name || out.contains(row.kept)) continue; + if (loss.winner.empty()) loss.winner = row.kept; + loss.rows.push_back(&row); + } + if (loss.rows.empty()) continue; + out.insert(name); + losses.emplace(name, std::move(loss)); + } + return losses; +} + +std::string conflict_winner(const Mod& mod) { + constexpr std::string_view prefix = "conflicts with "; + if (mod.problems.empty() || !mod.problems.front().starts_with(prefix)) return {}; + const auto& problem = mod.problems.front(); + return problem.substr(prefix.size(), problem.find(':') - prefix.size()); +} + +bool content_type(std::string_view root_type) { + const auto type = lower(root_type); + return type.find("appearance") != std::string::npos || type.ends_with("textureasset") || type.ends_with("meshasset"); +} + +std::string asset_area(std::string_view asset) { + const auto name = lower(asset); + if (name.find("lua") != std::string::npos) return "scripts"; + if (name.starts_with("gameplay/characters/data/recipe") || name.starts_with("characters/")) return "skater setup"; + static constexpr std::pair areas[]{ + {"ui", "menus"}, {"items", "items"}, {"levels", "maps"}, {"gameplay", "gameplay"}, {"audio", "sounds"}, + {"sound", "sounds"}, {"cameras", "cameras"}, {"camera", "cameras"}, {"physics", "physics"}, + {"thumbnail", "thumbnails"}, {"fx", "effects"}, {"vfx", "effects"}}; + const auto first = std::string_view(name).substr(0, name.find('/')); + for (const auto& [prefix, area] : areas) + if (first == prefix) return std::string(area); + return "game files"; +} + +} // namespace dingosdk::mods diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index 6e1110c4..df007ba1 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -216,9 +217,51 @@ struct StreamedTextures { std::uint64_t bytes{}; // what no longer stays loaded std::string example; }; +// `only`: just these of the region's files (a ScanCache's: the textures changed before). StreamedTextures stream_resident_textures(const CasStore& store, const fs::path& root, fb::BinaryBundle& manifest, std::vector& files, std::size_t first, - const std::set& tocChunks, const fs::path& gameRoot); + const std::set& tocChunks, const fs::path& gameRoot, + const std::vector* only = nullptr); + +// Which ReSkate.dll merges (its size and time): what it found reading a mod holds for it alone. +std::string sdk_stamp(); + +// What reading a mod's own bundles finds that its files alone decide: the EBX whose type references +// are repaired, a map's mesh sizes, the textures made to stream. Kept between merges in +// Mods/.reskate-scan.txt by each mod's fingerprint (mod_fingerprint) and the ReSkate that found it, +// so a merge reads again only the mods whose files changed: checking a big map's every EBX and mesh +// was most of a merge (a minute and more), on every merge, whichever mod had changed (2026-10-18). +// The superbundle jobs share one (thread-safe). +class ScanCache { +public: + struct Mesh { + std::uint32_t chunk{}; + std::uint16_t archive{}; + std::uint32_t offset{}; + std::uint64_t bytes{}; + }; + // One copy of a bundle, by the places of its files in the region. + struct Bundle { + std::vector repair; // EBX with type references to repair + std::vector textures; // textures whose header is changed so they stream + std::vector meshes; // a map's meshes (where each lies in the mod, and its bytes) + }; + // What `file` holds of the mods in `fingerprints` (folder name: mod_fingerprint) whose files are + // unchanged. Never throws: a missing or unreadable file holds nothing. + ScanCache(fs::path file, std::map> fingerprints) noexcept; + // By mod folder name and "superbundle|bundle" (lower case). + [[nodiscard]] std::optional find(const std::string& mod, const std::string& key) const; + void keep(const std::string& mod, const std::string& key, Bundle bundle); + // Writes it again, with the mods given only. Never throws. + void save() const noexcept; + +private: + fs::path file_; + std::map> fingerprints_; + mutable std::mutex mutex_; + std::map>, std::less<>> known_; +}; +inline constexpr wchar_t scan_cache_file[] = L".reskate-scan.txt"; // The loading screens the mods add (the rows of the game's loading-screen // configuration each one appends). Never throws; an unreadable mod is noted. @@ -320,17 +363,6 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, const fs::path& gameRoot, MergeReport& report, std::size_t threads = 0, bool background = false); -// Of two mods that change one of the game's assets in ways that cannot both be in the game, the -// one lower in the load order, found from the mods' asset lists before anything is merged: the -// rule the merge applies as it combines (MergeReport::conflicts), read off the bundles two or -// more mods ship, so the catalog can leave the losing mods out before the one merge that builds -// the patch. `mods` highest priority first; `output` is only where the patch would go. Never -// throws: what cannot be read is left to the merge, which still checks for itself. -std::vector find_conflicts(const std::vector& mods, - const std::map& modFiles, - const fs::path& dataRoot, const fs::path& output, - std::size_t threads = 0, bool background = false); - // A bundle's chunk metadata is a list with one record for each of its chunks: the // hash of the name of the resource the chunk belongs to ("h64") and what the // engine needs before it streams the chunk (a texture's first mip). In the game's @@ -369,6 +401,6 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::vector& sources, MergeReport& report, const std::string& relative, ArchiveUse& used, CasStore& store, std::uint16_t manifestArchive, const fs::path& gameRoot, const GridPlan& grid, - const AssetOverrides& overrides); + const AssetOverrides& overrides, ScanCache& scans); } // namespace dingosdk::mods::detail diff --git a/Engine/Vfs/mod_merge_scan_cache.cpp b/Engine/Vfs/mod_merge_scan_cache.cpp new file mode 100644 index 00000000..8e3062c8 --- /dev/null +++ b/Engine/Vfs/mod_merge_scan_cache.cpp @@ -0,0 +1,113 @@ +#include "mod_merge_internal.h" + +#include + +#include +#include + +namespace dingosdk::mods::detail { +namespace { +// The file's first line: its format, and the ReSkate that wrote it (sdk_stamp). Another ReSkate +// may read a mod differently, so what this one found is not taken on. +constexpr std::string_view scan_header = "ReSkate scan 1"; + +std::vector numbers(const std::string& text) { + std::vector list; + std::istringstream in(text); + for (std::uint32_t value{}; in >> value;) list.push_back(value); + return list; +} +} // namespace + +ScanCache::ScanCache(fs::path file, std::map> fingerprints) noexcept + : file_(std::move(file)), fingerprints_(std::move(fingerprints)) { + try { + std::ifstream in(file_, std::ios::binary); + std::string line; + if (!in || !std::getline(in, line)) return; + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line != std::string(scan_header) + ' ' + sdk_stamp()) return; + std::map>* mod{}; + Bundle* bundle{}; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + const auto tab = line.find('\t'); + if (tab == std::string::npos) continue; + const auto kind = line.substr(0, tab); + const auto rest = line.substr(tab + 1); + if (kind == "mod") { + // A mod's results count while its files are as they were (its fingerprint). + const auto split = rest.find('\t'); + const auto name = rest.substr(0, split); + const auto known = fingerprints_.find(name); + mod = split != std::string::npos && known != fingerprints_.end() && known->second == rest.substr(split + 1) + ? &known_[name] : nullptr; + bundle = nullptr; + } else if (kind == "b") { + bundle = mod ? &(*mod)[rest] : nullptr; + } else if (bundle && kind == "r") { + bundle->repair = numbers(rest); + } else if (bundle && kind == "t") { + bundle->textures = numbers(rest); + } else if (bundle && kind == "m") { + std::istringstream values(rest); + for (Mesh mesh; values >> mesh.chunk >> mesh.archive >> mesh.offset >> mesh.bytes;) bundle->meshes.push_back(mesh); + } + } + } catch (...) { + known_.clear(); // an unreadable file is as good as none + } +} + +std::optional ScanCache::find(const std::string& mod, const std::string& key) const { + std::lock_guard lock(mutex_); + const auto known = known_.find(mod); + if (known == known_.end()) return std::nullopt; + const auto bundle = known->second.find(key); + if (bundle == known->second.end()) return std::nullopt; + return bundle->second; +} + +void ScanCache::keep(const std::string& mod, const std::string& key, Bundle bundle) { + if (!fingerprints_.contains(mod)) return; + std::lock_guard lock(mutex_); + known_[mod][key] = std::move(bundle); +} + +void ScanCache::save() const noexcept { + try { + std::lock_guard lock(mutex_); + const auto temporary = fs::path(file_).concat(L".new"); + { + std::ofstream out(temporary, std::ios::binary | std::ios::trunc); + out << scan_header << ' ' << sdk_stamp() << '\n'; + const auto list = [&](const std::vector& values) { + std::string text; + for (const auto value : values) text += (text.empty() ? "" : " ") + std::to_string(value); + return text; + }; + for (const auto& [name, bundles] : known_) { + const auto fingerprint = fingerprints_.find(name); + if (fingerprint == fingerprints_.end()) continue; + out << "mod\t" << name << '\t' << fingerprint->second << '\n'; + for (const auto& [key, bundle] : bundles) { + out << "b\t" << key << '\n'; + if (!bundle.repair.empty()) out << "r\t" << list(bundle.repair) << '\n'; + if (!bundle.textures.empty()) out << "t\t" << list(bundle.textures) << '\n'; + if (!bundle.meshes.empty()) { + out << "m\t"; + for (std::size_t at = 0; at < bundle.meshes.size(); ++at) { + const auto& mesh = bundle.meshes[at]; + out << (at ? " " : "") << mesh.chunk << ' ' << mesh.archive << ' ' << mesh.offset << ' ' << mesh.bytes; + } + out << '\n'; + } + } + } + if (!out.flush()) return; + } + MoveFileExW(temporary.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); + } catch (...) {} +} + +} // namespace dingosdk::mods::detail diff --git a/Engine/Vfs/mod_merge_stamp.cpp b/Engine/Vfs/mod_merge_stamp.cpp index 92c80efb..247a1a51 100644 --- a/Engine/Vfs/mod_merge_stamp.cpp +++ b/Engine/Vfs/mod_merge_stamp.cpp @@ -44,6 +44,15 @@ fs::path sdk_module() { } // namespace +std::string sdk_stamp() { + std::error_code error; + const auto module = sdk_module(); + const auto size = fs::file_size(module, error); + if (error) return "none"; + const auto written = fs::last_write_time(module, error); + return std::to_string(size) + ' ' + std::to_string(error ? 0 : written.time_since_epoch().count()); +} + std::string merge_fingerprint(const Catalog& catalog, const std::vector& mods, const std::map& modFiles, bool storeKnown) { std::string inputs = stamp_header; diff --git a/Engine/Vfs/mod_merge_textures.cpp b/Engine/Vfs/mod_merge_textures.cpp index 65214f1e..352b1659 100644 --- a/Engine/Vfs/mod_merge_textures.cpp +++ b/Engine/Vfs/mod_merge_textures.cpp @@ -41,7 +41,8 @@ std::optional block_at(std::span encoded, std::u } // namespace StreamedTextures stream_resident_textures(const CasStore& store, const fs::path& root, fb::BinaryBundle& manifest, std::vector& files, std::size_t first, - const std::set& tocChunks, const fs::path& gameRoot) { + const std::set& tocChunks, const fs::path& gameRoot, + const std::vector* only) { StreamedTextures result; if (manifest.chunks.empty() || manifest.chunkMetadata.empty()) return result; std::optional records; @@ -57,6 +58,7 @@ StreamedTextures stream_resident_textures(const CasStore& store, const fs::path& for (std::size_t index = 0; index < manifest.resources.size(); ++index) { auto& asset = manifest.resources[index]; if (asset.resourceType != fb::texture_resource_type || resourcesAt + index >= files.size()) continue; + if (only && std::ranges::find(*only, static_cast(resourcesAt + index)) == only->end()) continue; const auto& file = files[resourcesAt + index]; if (!file.location.patch) continue; // the game's own texture try { diff --git a/Extension/Assets/mod_layers.cpp b/Extension/Assets/mod_layers.cpp index 2fd04e4c..7001eaf9 100644 --- a/Extension/Assets/mod_layers.cpp +++ b/Extension/Assets/mod_layers.cpp @@ -3,6 +3,7 @@ #include "Extension/UI/Overlay/overlay.h" #include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/mod_conflicts.h" #include "Engine/Vfs/mod_scoring.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" @@ -241,11 +242,12 @@ bool start_mod_layers(std::uintptr_t base, std::string& error) { } // A mod left out of the merge altogether loads none of its content, which // is worse, and used to say so only in the log. Name those too; the - // outdated ones have their own notice above. + // outdated ones have their own notice above, and one left out for a + // conflict is the player's choice of load order, shown in the launcher. { std::vector dropped; for (const auto& mod : catalog.excluded) - if (mod.outdated.empty()) dropped.push_back(&mod); + if (mod.outdated.empty() && mods::conflict_winner(mod).empty()) dropped.push_back(&mod); if (!dropped.empty()) { std::string names; for (const auto* mod : dropped) diff --git a/Extension/UI/Overlay/modding_menu.cpp b/Extension/UI/Overlay/modding_menu.cpp index c31f084b..d5648aba 100644 --- a/Extension/UI/Overlay/modding_menu.cpp +++ b/Extension/UI/Overlay/modding_menu.cpp @@ -4,6 +4,7 @@ #include "Engine/Vfs/builtin_packs.h" #include "Engine/Vfs/initfs.h" #include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/mod_conflicts.h" #include "Extension/Assets/live_mods.h" #include "Extension/BoardBreak/board_break.h" #include "Extension/UI/skate_theme.h" @@ -175,7 +176,6 @@ std::string mod_contents(const mods::Mod& mod) { // mod folder added since launch. void draw_installed_mods(SkateMenu& menu) { static auto* scans = new FolderRead; // process lifetime, as the threads that fill it - static auto* officials = new FolderRead>; static std::string action_error; const auto scanned = scans->get([] { return mods::scan_mods(mods::engine_data_root()); }, 1500); if (!scanned) { @@ -185,16 +185,12 @@ void draw_installed_mods(SkateMenu& menu) { const auto& list = *scanned; const auto& launch = mods::catalog(); const auto applied = live_mods::applied_mods(); - // Official mods (builtin_packs.h) live outside the Mods folder's list: ReSkate keeps them, - // always on. Without them here, the ones in effect read as mods whose folder was deleted. - const auto official_read = officials->get([root = list.root] { return mods::official_packs(root); }, 1500); - const auto official = official_read ? *official_read : std::vector{}; + // ReSkate's own packs (builtin_packs.h) live outside the Mods folder's list, always on. Without + // this, the ones in effect read as mods whose folder was deleted. (Official mods are mods like + // any other, in the list.) const auto kept = [&](const std::string& name) { const auto pack = mods::board_break_pack_of(name); - const auto& base = pack.empty() ? name : pack; - return base.starts_with("reskate-builtin-") || std::ranges::any_of(official, [&](const mods::BuiltinPack& other) { - return _stricmp(other.name.c_str(), base.c_str()) == 0; - }); + return (pack.empty() ? name : pack).starts_with("reskate-builtin-"); }; // A Board Break deck pack is in effect when one of the decks inside it is (mod_catalog.h). const auto in_effect = [&](const std::string& name) { @@ -205,6 +201,16 @@ void draw_installed_mods(SkateMenu& menu) { const auto excluded = [&](const std::string& name) { return std::ranges::any_of(launch.excluded, [&](const mods::Mod& mod) { return _stricmp(mod.name.c_str(), name.c_str()) == 0; }); }; + // Left out for a conflict: the mod it lost to (mod_conflicts.h), else empty. + const auto lost_to = [&](const std::string& name) { + const auto found = std::ranges::find_if(launch.excluded, [&](const mods::Mod& mod) { return _stricmp(mod.name.c_str(), name.c_str()) == 0; }); + return found == launch.excluded.end() ? std::string{} : mods::conflict_winner(*found); + }; + const auto title_of = [&](const std::string& name) { + for (const auto& entry : list.entries) + if (_stricmp(entry.mod.name.c_str(), name.c_str()) == 0) return entry.mod.title.empty() ? name : entry.mod.title; + return name; + }; // Board Break's other decks and other mods that bring a deck model: enabled, not loaded, and // nothing about them waits to be applied. const auto aside = mods::set_aside_for_board_break(); @@ -218,13 +224,6 @@ void draw_installed_mods(SkateMenu& menu) { if (effect) ++loaded; if (entry.enabled != effect && !excluded(entry.mod.name) && !set_aside(entry)) ++pending; } - const auto official_aside = [&](const std::string& name) { - return !in_effect(name) && std::ranges::any_of(aside, [&](const std::string& other) { return _stricmp(other.c_str(), name.c_str()) == 0; }); - }; - for (const auto& pack : official) { - if (in_effect(pack.name)) ++loaded; - else if (!official_aside(pack.name)) ++pending; // put in place since the game started: applying loads it - } // Mods still in effect whose folder has been deleted: applying unloads them. std::vector removed; for (const auto& name : applied) @@ -234,7 +233,10 @@ void draw_installed_mods(SkateMenu& menu) { pending += removed.size(); loaded += removed.size(); std::string count = std::to_string(loaded) + " loaded"; - if (!launch.excluded.empty()) count += ", " + std::to_string(launch.excluded.size()) + " not loaded"; + const auto conflicted = static_cast(std::ranges::count_if(launch.excluded, [](const mods::Mod& mod) { + return !mods::conflict_winner(mod).empty(); })); + if (launch.excluded.size() > conflicted) count += ", " + std::to_string(launch.excluded.size() - conflicted) + " not loaded"; + if (conflicted) count += ", " + std::to_string(conflicted) + " left out"; menu::begin_card(menu, "installed-mods", "INSTALLED MODS", count.c_str()); // Unapplied changes first, with the button that applies them. @@ -320,7 +322,11 @@ void draw_installed_mods(SkateMenu& menu) { const char* status = "DISABLED"; ImU32 colour = skate_theme::tile_light; std::string hover = mod.description; - if (excluded(mod.name)) { + if (const auto winner = lost_to(mod.name); !winner.empty()) { + status = "LEFT OUT"; + hover = "Left out: it changes the same parts of the game as " + title_of(winner) + ", which is higher in " + "the load order, so only that one loads.\n\nChoose which loads in the launcher's Mod Manager."; + } else if (excluded(mod.name)) { status = "NOT LOADED"; colour = skate_theme::danger; hover = "Not loaded: it could not be merged cleanly, so none of it is used.\n\n" @@ -343,23 +349,6 @@ void draw_installed_mods(SkateMenu& menu) { entry.enabled ? "Disable" : "Enable")) toggle = i; } - for (const auto& pack : official) { - const auto* known = [&]() -> const mods::Mod* { - for (const auto& mod : launch.mods) - if (_stricmp(mod.name.c_str(), pack.name.c_str()) == 0) return &mod; - return nullptr; - }(); - auto title = known && !known->title.empty() ? known->title : pack.name.substr(pack.name.find('-') + 1); - if (!known) std::replace(title.begin(), title.end(), '_', ' '); - auto line = known ? byline(*known) : "by " + pack.name.substr(0, pack.name.find('-')); - line += " - always on"; - const bool effect = in_effect(pack.name), set_by_deck = official_aside(pack.name); - tile(index++, title, line, effect ? "OFFICIAL" : set_by_deck ? "SET ASIDE" : "APPLY TO LOAD", - effect ? skate_theme::good : set_by_deck ? skate_theme::tile_light : skate_theme::danger, - known ? mod_contents(*known) : "", - "An official mod, made for one of ReSkate's own features. It is installed, updated and turned off " - "on the launcher's OFFICIAL tab, not here.", nullptr); - } for (const auto& name : removed) tile(index++, name, "Folder deleted", "APPLY TO UNLOAD", skate_theme::danger, "", "This mod's folder is gone, but it stays loaded until you apply.", nullptr); diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index b7f15622..e107bea0 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -477,8 +477,9 @@ int main() { 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. + // A copy the player installed from Thunderstore counts as the official mod where it is, rather + // than downloaded again; the deprecated first Board Break is retired and mods.json forgets it; + // every other mod stays as it was. { const auto store = root / L"official" / L"Mods"; fs::create_directories(store / L"Fourth_Studio-Road_Rash"); @@ -491,58 +492,88 @@ int main() { 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"; + const auto adopted = store / 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") && + mods::official_mod(adopted) && !fs::exists(store / L"Fourth_Studio-Board_Break") && fs::exists(store / L"Someone-Other_Mod" / L"layout.toc"), - "An installed official mod moves in, a retired one goes, the others stay"); + "An installed official mod is stamped where it is, 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"); - 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(remaining.issue.empty() && remaining.missing.empty() && remaining.entries.size() == 2 && + remaining.entries[0].mod.name == "Fourth_Studio-Road_Rash" && remaining.entries[0].enabled && + remaining.entries[1].mod.name == "Someone-Other_Mod" && !remaining.entries[1].enabled, + "mods.json forgets the retired one and keeps the rest as it was, the official one among them"); + check(!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::official_mod(store / L"Someone-Other_Mod"), + "Only a retired mod is left out; the official one is a mod like any other"); check(official_mods::adopt_and_retire(store, official).empty(), "Nothing is done twice"); - // Turned off in the launcher: still in place, but the game takes it in no more. - check(!mods::official_off(store, "Fourth_Studio-Road_Rash") && mods::set_official_off(store, "Fourth_Studio-Road_Rash", true) && - mods::official_off(store, "fourth_studio-road_rash") && mods::official_packs(store).empty() && - mods::official_packs(store, true).size() == 1 && fs::exists(adopted / L"layout.toc"), - "An official mod turned off stays in place and is left out"); - check(mods::set_official_off(store, "Fourth_Studio-Road_Rash", true) && mods::set_official_off(store, "Fourth_Studio-Road_Rash", false) && - !mods::official_off(store, "Fourth_Studio-Road_Rash") && mods::official_packs(store).size() == 1 && - read(store / L".reskate-official" / L"official-off.txt").empty(), - "Turned on again, it is taken in again"); + // Switched off: still in place, in mods.json as off, and its feature does not start. + check(!mods::official_off(store, "Fourth_Studio-Road_Rash") && mods::official_in_use(store, "Fourth_Studio-Road_Rash") && + mods::set_official_off(store, "Fourth_Studio-Road_Rash", true) && mods::official_off(store, "fourth_studio-road_rash") && + !mods::official_in_use(store, "Fourth_Studio-Road_Rash") && fs::exists(adopted / L"layout.toc"), + "An official mod switched off stays in place and its feature is off"); + const auto switched = mods::scan_mods(store.parent_path()); + check(switched.entries.size() == 2 && switched.entries[0].mod.name == "Fourth_Studio-Road_Rash" && !switched.entries[0].enabled && + !switched.entries[1].enabled, + "It is switched off in mods.json, in its place, and nothing else changed"); + check(mods::set_official_off(store, "Fourth_Studio-Road_Rash", false) && !mods::official_off(store, "Fourth_Studio-Road_Rash") && + mods::official_in_use(store, "Fourth_Studio-Road_Rash"), + "Switched on again, its feature is on again"); check(!mods::set_official_off(store, "a/b", true) && !mods::set_official_off(store, "", true), "Only a folder name is written"); } + // Official mods kept where they were before they were mods like any other move into the Mods + // folder, at the bottom of the load order, switched off when they were turned off there. + { + const auto store = root / L"moved" / L"Mods"; + const auto kept = store / L".reskate-official"; + fs::create_directories(kept / L"Fourth_Studio-Board_Break_Pack"); + fs::create_directories(kept / L"Fourth_Studio-Road_Rash"); + fs::create_directories(store / L"Someone-Other_Mod"); + std::ofstream(kept / L"Fourth_Studio-Board_Break_Pack" / L"layout.toc") << "toc"; + std::ofstream(kept / L"Fourth_Studio-Board_Break_Pack" / L".official-version") << "2.0.0\n"; + std::ofstream(kept / L"Fourth_Studio-Road_Rash" / L"layout.toc") << "toc"; + std::ofstream(kept / L"Fourth_Studio-Road_Rash" / L".official-version") << "1.0.0\n"; + std::ofstream(kept / L"official-off.txt") << "fourth_studio-road_rash\n"; + std::ofstream(kept / L"official-mods.json") << R"({"mods":[]})"; + std::ofstream(store / L"Someone-Other_Mod" / L"layout.toc") << "toc"; + std::ofstream(store / L"mods.json") << R"({"schema":1,"mods":[{"name":"Someone-Other_Mod","enabled":true}]})"; + const auto done = mods::move_official_mods(store); + const auto moved = mods::scan_mods(store.parent_path()); + check(done.size() == 2 && mods::official_mod(store / L"Fourth_Studio-Board_Break_Pack") && + mods::official_mod(store / L"Fourth_Studio-Road_Rash") && !fs::exists(kept / L"Fourth_Studio-Road_Rash") && + !fs::exists(kept / L"official-off.txt") && fs::exists(kept / L"official-mods.json"), + "The official mods move into the Mods folder; the list read last stays where it was"); + check(moved.issue.empty() && moved.entries.size() == 3 && moved.entries[0].mod.name == "Someone-Other_Mod" && + moved.entries[1].enabled != moved.entries[2].enabled && mods::official_off(store, "Fourth_Studio-Road_Rash") && + !mods::official_off(store, "Fourth_Studio-Board_Break_Pack"), + "They go below the player's mods, the one turned off switched off"); + check(mods::move_official_mods(store).empty(), "Nothing is moved 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); + const auto in_place = [&] { + std::vector found; + for (const auto& entry : fs::directory_iterator(store)) + if (entry.is_directory() && mods::official_mod(entry.path())) found.push_back(entry.path()); + return found; + }; official_mods::start(store); check(official_mods::wait(std::chrono::minutes(1)), "The list was read in time"); - check(mods::official_packs(store).empty(), "Nothing is downloaded until asked"); + check(in_place().empty(), "Nothing is downloaded until asked"); for (const auto& mod : official_mods::snapshot().mods) official_mods::install(store, mod.full_name); check(official_mods::wait(std::chrono::minutes(5)), "The official mods were installed 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)) { + for (const auto& folder : in_place()) { 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"; + for (const auto& entry : fs::recursive_directory_iterator(folder)) files += entry.is_regular_file(); + std::cout << " in place: " << folder.filename().string() << ", " << files << " files\n"; } - check(!state.mods.empty() && mods::official_packs(store).size() == state.mods.size(), "Every official mod is in place"); + check(!state.mods.empty() && in_place().size() == state.mods.size(), "Every official mod is in place"); } std::error_code ignored; diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index 9f279ba0..8898c7ee 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -5,6 +5,9 @@ #include "problem.h" +#include "Engine/Core/Json/json.h" +#include "Engine/Core/Log/logging.h" + #include #include #include @@ -270,8 +273,9 @@ bool mods_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 siz // The STATUS tile: what ReSkate is doing, or what went wrong and what to do // about it. Laid out like the HUB's BOUNTIES tile, with the state in its edge. -void status_tile(const Fonts& fonts, const State& state, ImVec2 position, float width, float time, - const fs::path& logs, bool modal) { +// Returns where the tile ends, so what goes under it starts there. +float status_tile(const Fonts& fonts, const State& state, ImVec2 position, float width, float time, + const fs::path& logs, bool modal) { auto* draw = ImGui::GetWindowDrawList(); const bool failed = state.phase == Phase::failed; const auto problem = failed ? launcher_problem::explain(state.status) : launcher_problem::Problem{}; @@ -368,6 +372,109 @@ void status_tile(const Fonts& fonts, const State& state, ImVec2 position, float y += fonts.body->FontSize + S(12); } } + return end.y; +} + +// Reads the announcement in the background once it is due (as the launcher starts, then every ten +// minutes), and takes in a read once it is done. A read that fails keeps what is shown. +void refresh_announcement(Announcement& news, double time) { + { + std::lock_guard lock(news.mutex); + if (news.arrived) { + news.arrived = false; + news.shown = std::move(news.incoming); + news.text = news.shown ? thunderstore::readme_lines(news.shown->markdown, 400) : thunderstore::Readme{}; + } + } + if (time < news.next_read || news.loading.exchange(true)) return; + news.next_read = time + 600; + if (news.worker.joinable()) news.worker.join(); + news.worker = std::thread([&news] { + std::optional found; + bool read{}; + try { + std::string body; + launcher_update::http_stream(L"https://api.reskate.dev/api/v1/announcement", 64 * 1024, 15000, + [&](const char* data, DWORD size) { body.append(data, size); }); + const auto root = Json::parse(body); + const auto& entry = root.at("announcement"); + if (entry.is_object()) { + Announcement::Message message; + message.version = entry.at("version").get(); + message.important = entry.value("level", std::string()) == "important"; + message.title = entry.at("title").string(); + message.markdown = entry.value("markdown", std::string()); + message.link = entry.value("link", std::string()); + if (!message.link.starts_with("https://")) message.link.clear(); + message.link_label = entry.value("link_label", std::string()); + if (message.link_label.empty()) message.link_label = "Learn more"; + if (!message.title.empty()) found = std::move(message); + } + read = true; + } catch (const std::exception& failure) { + logging::write(logging::Level::warning, logging::Channel::launcher, + std::string("Could not read the announcement: ") + failure.what()); + } + { + std::lock_guard lock(news.mutex); + if (read) { + news.incoming = std::move(found); + news.arrived = true; + } + } + news.loading = false; + }); +} + +// The announcement, under STATUS and down to `bottom` at most: NEWS (or IMPORTANT, in the warning +// colour), its title and text, the button to its link, and HIDE, which keeps this version hidden. +// Its text scrolls when it is longer than the room. +void announcement_card(Launcher& launcher, const Fonts& fonts, Ui& ui, ImVec2 position, float width, float bottom) { + auto& news = ui.announcement; + if (!news.shown || news.shown->version <= launcher.settings().announcement_hidden) return; + const auto& message = *news.shown; + const float pad = S(20); + const float text_x = position.x + pad, text_width = width - pad * 2; + const float title_y = position.y + S(54); + const float title_height = fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, text_width, message.title.c_str()).y; + const float link = message.link.empty() ? 0.0f : S(10) + S(32); + const float body_room = bottom - (title_y + title_height + S(8)) - link - pad; + if (body_room < S(24) && !news.text.lines.empty()) return; // (no room: a window this small shows none) + + auto* draw = ImGui::GetWindowDrawList(); + draw->ChannelsSplit(2); + draw->ChannelsSetCurrent(1); + const ImU32 accent = message.important ? color::warning : color::blue; + draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(text_x, position.y + S(14)), color::text, + message.important ? "IMPORTANT" : "NEWS"); + ImGui::SetCursorScreenPos(ImVec2(position.x + width - pad - S(64), position.y + S(12))); + if (ImGui::Button("HIDE##announcement", ImVec2(S(64), 0))) { + launcher.settings().announcement_hidden = message.version; + launcher.save(); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Hide this announcement. The next one shows when it is posted."); + draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, title_y), color::text, message.title.c_str(), nullptr, text_width); + float end = title_y + title_height; + if (!news.text.lines.empty()) { + ImGui::SetCursorScreenPos(ImVec2(text_x, end + S(8))); + ImGui::SetNextWindowSizeConstraints(ImVec2(0, 0), ImVec2(FLT_MAX, body_room)); + ImGui::BeginChild("##announcement_text", ImVec2(text_width, 0), ImGuiChildFlags_AutoResizeY, ImGuiWindowFlags_NoBackground); + draw_readme(fonts, news.text); + ImGui::EndChild(); + end = ImGui::GetItemRectMax().y; + } + if (!message.link.empty()) { + ImGui::SetCursorScreenPos(ImVec2(text_x, end + S(10))); + push_primary_button(); + if (ImGui::Button((message.link_label + "##announcement_link").c_str(), ImVec2(0, S(32)))) open_url(message.link); + pop_primary_button(); + end = ImGui::GetItemRectMax().y; + } + const ImVec2 corner(position.x + width, end + pad); + draw->ChannelsSetCurrent(0); + rough_rect(draw, position, corner, color::tile, 7); + draw->AddRectFilled(position, ImVec2(position.x + S(4), corner.y), accent); + draw->ChannelsMerge(); } } // namespace @@ -407,8 +514,11 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane // MODS tile can say when installed mods have updates. collect_listing(mods_panel); refresh_listing(mods_panel, time); + prepare_mods(mods_panel, launcher.session()); // Whether the privacy policy or terms changed since the player agreed (looked up once). check_legal_versions(ui, launcher.settings()); + // The team's announcement for the home screen (offline: none is read). + if (!launcher.settings().offline) refresh_announcement(ui.announcement, time); // A mod dropped on the window opens the Mods panel and installs it. { std::lock_guard lock(g_dropped_mutex); @@ -522,11 +632,16 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane if (entry.enabled) ++enabled_mods; } // A mod the game threw out of the merge is named here, so nobody has - // to wonder which one stopped working. + // to wonder which one stopped working. (One left out for a conflict is + // no fault of its own: the mod manager shows it with the mod it lost to.) std::vector dropped; - for (const auto& entry : list.entries) - if (entry.enabled && (list.excluded.contains(entry.mod.name) || !entry.mod.outdated.empty())) + for (const auto& entry : list.entries) { + const auto excluded = list.excluded.find(entry.mod.name); + const bool conflict = excluded != list.excluded.end() && !excluded->second.empty() && + excluded->second.front().starts_with("conflicts with "); + if (entry.enabled && ((excluded != list.excluded.end() && !conflict) || !entry.mod.outdated.empty())) dropped.push_back(entry.mod.title); + } const auto pending = updates(mods_panel.store, installed_versions(list, true)).size(); ui.mods_pending = pending; ui.mods_mark_bad = !dropped.empty(); @@ -554,7 +669,11 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) act(); ImGui::EndDisabled(); - status_tile(fonts, state, ImVec2(S(60), S(200)), S(430), time, launcher.session().paths.logs, modal); + const float status_end = status_tile(fonts, state, ImVec2(S(60), S(200)), S(430), time, launcher.session().paths.logs, modal); + // Under it, down to the changelog's top: the announcement. + ImGui::BeginDisabled(modal); + announcement_card(launcher, fonts, ui, ImVec2(S(60), status_end + S(16)), S(430), size.y - S(56) - changelog_size.y - S(16)); + ImGui::EndDisabled(); if (modal) draw->AddRectFilled(ImVec2(0, 0), size, rgba(4, 6, 9, 0.72f)); g_drag_allowed = !modal && !ImGui::IsAnyItemHovered(); diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index cbfc9dc6..85740841 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -7,6 +7,7 @@ #include "mod_profiles.h" #include "thunderstore.h" +#include "Engine/Vfs/mod_conflicts.h" #include "Engine/Vfs/mod_list.h" #include "Extension/UI/skate_theme.h" @@ -212,6 +213,8 @@ struct Settings { bool steam_prefer_code{}; // The versions of the privacy policy and the terms of service the player agreed to (0: none). std::uint64_t accepted_privacy{}, accepted_terms{}; + // The version of the home screen's announcement the player hid (0: none): one newer shows. + std::uint64_t announcement_hidden{}; }; inline constexpr std::array log_levels{"trace", "debug", "info", "warning", "error", "critical", "off"}; @@ -227,7 +230,6 @@ struct ModProblem { std::string name; // folder under Mods/, so it can be switched off std::string title; std::string reason; - bool conflict{}; // left out for another mod above it that changes the same game files }; struct State { @@ -401,8 +403,31 @@ struct GamePages { }; // Panels the main screen can show; one at a time. +// The announcement the ReSkate backend has for the home screen (GET +// https://api.reskate.dev/api/v1/announcement), read as the launcher starts and every ten minutes, +// so it is written in the admin panel and reaches players without a release. +struct Announcement { + struct Message { + std::uint64_t version{}; + bool important{}; + std::string title, markdown, link, link_label; // link: empty, or an https URL + }; + std::thread worker; + std::atomic loading{}; + std::mutex mutex; + bool arrived{}; // worker -> UI, under the mutex + std::optional incoming; + std::optional shown; // none: there is nothing to show + thunderstore::Readme text; + double next_read{}; // ImGui time of the next read + ~Announcement() { + if (worker.joinable()) worker.join(); + } +}; + struct Ui { bool settings{}; + Announcement announcement; GamePages game; bool changelog{}; Changelog notes; @@ -541,6 +566,42 @@ struct ModsPanel { int order{}; std::set> marked; std::string anchor; // the last mod ticked: where a Shift-click range starts + int place{}; // typed in a row's "move to position" popup + // The installed mods that are official mods (Engine/Vfs/builtin_packs.h), by folder name: found + // when the folder is scanned. + std::set> officials; + int shown_tab{-1}; // the tab drawn last, so MY MODS reads the folder again when it is opened + // The conflicts the player settled (gui_mods.cpp): of each pair, (the mod that loads, the one left out). + std::set> conflict_choices; + bool resolving{}; // the Resolve conflicts popup is to open + std::vector resolve_order; // the load order as the popup opened: its rows keep it + // MY MODS rows as drawn: each mod's height in the list, eased toward its place there, so a row + // slides to where it goes rather than jumping (gui_mods.cpp). + std::map> row_y; + std::string drag_name; // the row being dragged by its handle + float drag_grab{}; // how far below its top the pointer took hold of it + // MY MODS: conflicts between the enabled mods as the list stands (mods::ConflictChecker), + // checked off the UI thread a moment after each change. The worker hands its result over + // in `pending` (under `mutex`); the UI thread takes it from there, so `rows` and what points + // into them (`losses`) only change on the UI thread. + struct ConflictCheck { + std::unique_ptr checker; // the worker's alone while it runs + std::thread worker; + std::atomic running{}; + double due{-1}; // ImGui time the next check starts; -1 when none is asked + std::uint64_t asked{}, checked{}; // which change the latest result is for + bool ready{}; // a result is in + std::vector rows; + std::vector order; // the mods checked, highest priority first + std::map> losses; // the mods that would be left out + std::map, std::less<>> beats; // and whom each winner leaves out + struct Result { + bool ready{}; + std::uint64_t asked{}; + std::vector rows; + std::vector order; + } pending; // under mutex + } conflicts; std::string message; bool message_error{}; std::vector confirm_remove; // folders awaiting "Uninstall" confirmation @@ -585,12 +646,20 @@ struct ModsPanel { cancel = true; if (worker.joinable()) worker.join(); if (profile_worker.joinable()) profile_worker.join(); + if (conflicts.worker.joinable()) conflicts.worker.join(); } }; void scan(ModsPanel& panel, const launcher_app::Session& session); // Re-reads the Mods folder and the Thunderstore listing. void refresh_mods(Launcher& launcher, ModsPanel& panel); +// Every frame, from the launcher's start: checks the enabled mods for conflicts in the background +// before the Mod Manager is first opened, so its list has the answer ready, and takes in checks as +// they finish. (Opening it reads the folder again; the check then reads only what changed.) +void prepare_mods(ModsPanel& panel, const launcher_app::Session& session); +// Waits (at most ten seconds) for a conflict check under way to finish: it may be reading the mod +// files an install or uninstall is about to replace. Off the UI thread where it can be. +void wait_for_conflict_check(const ModsPanel& panel); void start_install(ModsPanel& panel, const fs::path& source, bool replace); // The Windows file (or folder) picker; empty when cancelled. A file picker shows // `filter_pattern` ("*.zip") under `filter_name`. diff --git a/Launcher/gui_launcher.cpp b/Launcher/gui_launcher.cpp index d644c653..e84df6da 100644 --- a/Launcher/gui_launcher.cpp +++ b/Launcher/gui_launcher.cpp @@ -73,6 +73,7 @@ Settings load_settings(const fs::path& path) { settings.steam_prefer_code = json.value("steam_prefer_code", settings.steam_prefer_code); settings.accepted_privacy = json.value("accepted_privacy_version", settings.accepted_privacy); settings.accepted_terms = json.value("accepted_terms_version", settings.accepted_terms); + settings.announcement_hidden = json.value("announcement_hidden", settings.announcement_hidden); } catch (const std::exception& exception) { logging::log(logging::Level::warning, logging::Channel::launcher, "Ignoring launcher settings: {}", exception.what()); } @@ -101,6 +102,7 @@ void save_settings(const fs::path& path, const Settings& settings) { json["steam_remember"] = settings.steam_remember; json["steam_prefer_code"] = settings.steam_prefer_code; json["accepted_privacy_version"] = settings.accepted_privacy; + json["announcement_hidden"] = settings.announcement_hidden; json["accepted_terms_version"] = settings.accepted_terms; std::ofstream output(path, std::ios::binary | std::ios::trunc); output << json.dump(2) << '\n'; @@ -500,19 +502,18 @@ bool Launcher::run_mod_merge() { if (!catalog.issue.empty()) problems.push_back({{}, "The Mods folder", catalog.issue}); for (const auto& mod : catalog.excluded) { - // Mods built for another game version say so for themselves, in game - // and in the mod manager; they do not hold up a launch. + // Mods built for another game version say so for themselves, in game and in the mod + // manager, and a mod left out for a conflict is shown there with the mod it lost to and + // what to do about it: neither holds up a launch. if (!mod.outdated.empty()) continue; const auto reason = mod.problems.empty() ? std::string("it could not be merged cleanly") : mod.problems.front(); - problems.push_back({mod.name, mod.title.empty() ? mod.name : mod.title, reason, reason.starts_with("conflicts with ")}); + if (reason.starts_with("conflicts with ")) continue; + problems.push_back({mod.name, mod.title.empty() ? mod.name : mod.title, reason}); } if (problems.empty()) return true; std::lock_guard lock(mutex_); state_.phase = Phase::mods_broken; - const bool conflicts = std::ranges::all_of(problems, &ModProblem::conflict); - state_.status = problems.size() == 1 ? (conflicts ? "A mod conflicts with another" : "A mod could not be merged") - : conflicts ? std::format("{} mods conflict with others", problems.size()) - : std::format("{} mods could not be merged", problems.size()); + state_.status = problems.size() == 1 ? "A mod could not be merged" : std::format("{} mods could not be merged", problems.size()); state_.detail.clear(); state_.progress = -1; state_.mod_problems = std::move(problems); diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 407351ff..e7d02331 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -1,7 +1,10 @@ #include "gui_internal.h" +#include + #include "mod_manager.h" #include "official_mods.h" +#include "Engine/Vfs/builtin_packs.h" #include "Engine/Core/Log/logging.h" @@ -9,7 +12,12 @@ #include #include +#include #include +#include +#include +#include +#include using Microsoft::WRL::ComPtr; @@ -19,9 +27,68 @@ using Microsoft::WRL::ComPtr; namespace dingosdk::launcher_gui::detail { namespace { +// The list changed: check it for conflicts again once it has been still for a moment, so a mod +// dragged about is checked once, where it lands. +void want_conflict_check(ModsPanel& panel, double delay = 0.5) { + panel.conflicts.due = ImGui::GetTime() + delay; + ++panel.conflicts.asked; +} + +// Takes in a finished conflict check, and starts the one asked for once it is due and none runs. +void pump_conflict_check(ModsPanel& panel) { + auto& check = panel.conflicts; + { + std::lock_guard lock(panel.mutex); + if (check.pending.ready) { + check.pending.ready = false; + check.rows = std::move(check.pending.rows); + check.order = std::move(check.pending.order); + check.checked = check.pending.asked; + check.ready = true; + // A mod left out for a reason of its own (it could not be merged) is out whoever wins. + std::set> out; + for (const auto& [name, problems] : panel.list.excluded) + if (problems.empty() || !problems.front().starts_with("conflicts with ")) out.insert(name); + check.losses = mods::conflict_losses(check.order, check.rows, out); + check.beats.clear(); + for (const auto& [lost, loss] : check.losses) check.beats[loss.winner].push_back(lost); + } + } + if (!check.running && check.worker.joinable()) check.worker.join(); + // (Not while mods are being installed or updated: it reads the files they replace.) + if (check.due < 0 || ImGui::GetTime() < check.due || check.running || panel.root.empty() || panel.installing) return; + check.due = -1; + // The worker gets its own copy of the mods: the list can change while it runs. + auto snapshot = std::make_shared>(); + for (const auto& entry : panel.list.entries) + if (entry.enabled && entry.mod.provides_layout && entry.mod.outdated.empty()) snapshot->push_back(entry.mod); + if (!check.checker) check.checker = std::make_unique(panel.root.parent_path()); + check.running = true; + try { + check.worker = std::thread([&panel, snapshot, asked = check.asked] { + std::vector order; + std::vector names; + for (const auto& mod : *snapshot) { + order.push_back(&mod); + names.push_back(mod.name); + } + // Half the cores, under normal priority: the launcher stays quick while it runs. + auto rows = panel.conflicts.checker->check(order, std::max(1U, std::thread::hardware_concurrency() / 2), true); + { + std::lock_guard lock(panel.mutex); + panel.conflicts.pending = {true, asked, std::move(rows), std::move(names)}; + } + panel.conflicts.running = false; + }); + } catch (const std::system_error&) { + check.running = false; + } +} + void save(ModsPanel& panel) { try { mods::save_mod_order(panel.root, panel.list.entries); + want_conflict_check(panel); panel.list.issue.clear(); panel.message = "Saved. Changes apply the next time Skate starts."; panel.message_error = false; @@ -31,7 +98,8 @@ void save(ModsPanel& panel) { } } -// Picks up a finished install: rescan and select the new mod, or ask to replace. +// Picks up a finished install: rescan and say what came in, or ask to replace. (Its details are not +// opened: the player may be browsing on, and finds the mod in the list.) void collect_install(ModsPanel& panel, const launcher_app::Session& session) { std::lock_guard lock(panel.mutex); if (!panel.finished) return; @@ -49,8 +117,6 @@ void collect_install(ModsPanel& panel, const launcher_app::Session& session) { panel.message_error = true; return; } - for (std::size_t i = 0; i < panel.list.entries.size(); ++i) - if (panel.list.entries[i].mod.name == panel.finished_name) panel.selected = static_cast(i); panel.message_error = false; if (!panel.finished_note.empty()) { panel.message = panel.finished_note; // Thunderstore installs say what they did and log it themselves @@ -209,13 +275,18 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width, constexpr std::array filter_names{"All mods", "Enabled", "Disabled", "Maps", "Game data", "Updates", "Problems", "Conflicts"}; constexpr std::array order_names{"Load order", "Name", "Largest first"}; +// What a row dragged by its handle carries: its place in the load order. +constexpr const char* dragged_row = "RESKATE_MOD_ROW"; // Something the page was asked to do to one mod or to all the ticked ones. // Carried out once the list is drawn: most of it reorders or shrinks the list. struct Request { - enum class Kind { none, enable, disable, top, bottom, up, down, update, uninstall }; + enum class Kind { none, enable, disable, top, bottom, up, down, move, place, update, uninstall }; Kind kind{Kind::none}; std::vector mods; // folder names + std::string target; // move: the mod they go next to + bool above{}; // move: above it, not below + int position{}; // place: where in the load order, counted from 1 }; std::string folded(std::string_view text) { @@ -225,25 +296,128 @@ std::string folded(std::string_view text) { } bool update_waiting(const ModsPanel& panel, const thunderstore::Installed& installed, const std::string& name) { + // (An official mod is updated on the OFFICIAL tab.) + if (panel.officials.contains(name)) return false; const auto* package = package_for(panel.store, name); return package && thunderstore::update_available(*package, installed); } -// Left out because it conflicts with a mod higher in the load order (mod_catalog.cpp): that -// mod's folder name, else empty. +// Left out at the last launch for a reason of its own (it could not be merged), not a conflict. +bool broken(const ModsPanel& panel, const std::string& name) { + const auto found = panel.list.excluded.find(name); + return found != panel.list.excluded.end() && (found->second.empty() || !found->second.front().starts_with("conflicts with ")); +} + +// The mod `name` loses a conflict to, so that only that one loads: as the latest check of the list +// found it, or before one is in, as the last launch did (mod_catalog.cpp). Empty when none. std::string conflict_winner(const ModsPanel& panel, const std::string& name) { + if (panel.conflicts.ready) { + const auto found = panel.conflicts.losses.find(name); + return found == panel.conflicts.losses.end() ? std::string{} : found->second.winner; + } constexpr std::string_view prefix = "conflicts with "; const auto found = panel.list.excluded.find(name); if (found == panel.list.excluded.end() || found->second.empty() || !found->second.front().starts_with(prefix)) return {}; const auto& problem = found->second.front(); return problem.substr(prefix.size(), problem.find(':') - prefix.size()); } +// What that conflict is over, for a player: "both change menus and skater setup (52 files)". +// Empty before a check is in. +std::string conflict_reason(const ModsPanel& panel, const std::string& name) { + const auto found = panel.conflicts.losses.find(name); + if (!panel.conflicts.ready || found == panel.conflicts.losses.end()) return {}; + std::vector areas; + std::size_t files{}; + for (const auto* row : found->second.rows) { + files += row->assets; + for (const auto& area : row->areas) + if (std::ranges::find(areas, area) == areas.end()) areas.push_back(area); + } + std::string text; + for (std::size_t at = 0; at < areas.size(); ++at) + text += (at == 0 ? "" : at + 1 == areas.size() ? " and " : ", ") + areas[at]; + return "both change " + (text.empty() ? std::string("the same game files") : text) + + std::format(" ({} {})", files, files == 1 ? "file" : "files"); +} +// The mods `name` wins over in the latest check: they are left out for it. +const std::vector* conflict_beats(const ModsPanel& panel, const std::string& name) { + const auto found = panel.conflicts.beats.find(name); + return panel.conflicts.ready && found != panel.conflicts.beats.end() ? &found->second : nullptr; +} // A mod's title, by its folder name. std::string title_of(const ModsPanel& panel, const std::string& name) { for (const auto& entry : panel.list.entries) if (entry.mod.name == name) return entry.mod.title.empty() ? name : entry.mod.title; return name; } +// The conflicts the player has settled in the Resolve conflicts popup: of each pair, the mod they +// chose to load, kept in Mods/.reskate-conflict-choices.txt as "loadsleft out" lines. A +// conflict that comes out as chosen is not asked about again; one that comes out the other way (a +// mod moved past the other) or is new is. +constexpr wchar_t conflict_choices_file[] = L".reskate-conflict-choices.txt"; + +void read_conflict_choices(ModsPanel& panel) { + panel.conflict_choices.clear(); + std::ifstream in(panel.root / conflict_choices_file, std::ios::binary); + for (std::string line; std::getline(in, line);) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + const auto tab = line.find('\t'); + if (tab != std::string::npos && tab > 0 && tab + 1 < line.size()) + panel.conflict_choices.emplace(line.substr(0, tab), line.substr(tab + 1)); + } +} + +void write_conflict_choices(const ModsPanel& panel) { + std::ofstream out(panel.root / conflict_choices_file, std::ios::binary | std::ios::trunc); + for (const auto& [loads, left_out] : panel.conflict_choices) out << loads << '\t' << left_out << '\n'; +} + +// One conflict as the latest check has it: the mod that loads, the one left out for it, and what +// they both change. +struct ConflictPair { + std::string loads, left_out; + const mods::MergeReport::Conflict* row{}; +}; +// Every conflict that leaves a mod out, the highest left-out mod first. +std::vector conflict_pairs(const ModsPanel& panel) { + std::vector pairs; + if (!panel.conflicts.ready) return pairs; + for (const auto& name : panel.conflicts.order) + if (const auto loss = panel.conflicts.losses.find(name); loss != panel.conflicts.losses.end()) + for (const auto* row : loss->second.rows) pairs.push_back({row->kept, name, row}); + return pairs; +} +bool settled(const ModsPanel& panel, const ConflictPair& pair) { + return panel.conflict_choices.contains({pair.loads, pair.left_out}); +} +// Whether a mod left out for a conflict is so because the player chose it (every conflict it loses +// is settled). Before a check is in, none is. +bool left_out_by_choice(const ModsPanel& panel, const std::string& name) { + bool any{}; + for (const auto& pair : conflict_pairs(panel)) { + if (pair.left_out != name) continue; + if (!settled(panel, pair)) return false; + any = true; + } + return any; +} +// What two mods both change, for a player: "Both change scripts (6 files)". +std::string pair_reason(const ConflictPair& pair) { + std::string areas; + for (std::size_t at = 0; at < pair.row->areas.size(); ++at) + areas += (at == 0 ? "" : at + 1 == pair.row->areas.size() ? " and " : ", ") + pair.row->areas[at]; + return "Both change " + (areas.empty() ? std::string("the same game files") : areas) + + std::format(" ({} {})", pair.row->assets, pair.row->assets == 1 ? "file" : "files"); +} + +// What a mod brings, for the list's Type column. +std::string kind_of(const ModsPanel& panel, const mods::Mod& mod) { + if (panel.officials.contains(mod.name)) return "Official"; + if (!mod.levels.empty()) return mod.levels.size() == 1 ? "Map" : std::format("{} maps", mod.levels.size()); + if (!mod.park_maps.empty()) return "Park"; + if (mod.provides_layout) return "Game data"; + return "Nothing"; +} // The rows the list shows, as places in the load order: what the search and the // filter leave, in the order asked for. @@ -261,8 +435,8 @@ std::vector visible_mods(const ModsPanel& panel, const thunderstore::Instal case 3: keep = !mod.levels.empty(); break; case 4: keep = mod.levels.empty() && mod.provides_layout; break; case 5: keep = update_waiting(panel, installed, mod.name); break; - case 6: keep = !mod.outdated.empty() || (panel.list.excluded.contains(mod.name) && conflict_winner(panel, mod.name).empty()); break; - case 7: keep = mod.outdated.empty() && !conflict_winner(panel, mod.name).empty(); break; + case 6: keep = !mod.outdated.empty() || broken(panel, mod.name); break; + case 7: keep = mod.outdated.empty() && (!conflict_winner(panel, mod.name).empty() || conflict_beats(panel, mod.name)); break; default: break; } if (keep && !query.empty()) @@ -282,11 +456,6 @@ std::vector visible_mods(const ModsPanel& panel, const thunderstore::Instal }); else if (panel.order == 2) std::stable_sort(view.begin(), view.end(), [&](int a, int b) { return size(a) > size(b); }); - // Mods with an update waiting come first, whatever the order: they are what the player has - // something to do about. Among themselves, and the rest among themselves, the order stands. - std::stable_partition(view.begin(), view.end(), [&](int index) { - return update_waiting(panel, installed, entries[static_cast(index)].mod.name); - }); return view; } @@ -307,21 +476,44 @@ void mark(ModsPanel& panel, const std::vector& view, int position, bool on, panel.anchor = name(position); } +// The mods a drag of the row at `index` moves: every ticked one when it is ticked among others, +// in their load order, else that one. +std::vector dragged_mods(const ModsPanel& panel, int index) { + const auto& entries = panel.list.entries; + if (index < 0 || index >= static_cast(entries.size())) return {}; + const auto& name = entries[static_cast(index)].mod.name; + if (!panel.marked.contains(name) || panel.marked.size() < 2) return {name}; + std::vector names; + for (const auto& entry : entries) + if (panel.marked.contains(entry.mod.name)) names.push_back(entry.mod.name); + return names; +} + void carry_out(ModsPanel& panel, const thunderstore::Installed& installed, const Request& request) { auto& entries = panel.list.entries; const auto asked = [&](const mods::ModEntry& entry) { return std::find(request.mods.begin(), request.mods.end(), entry.mod.name) != request.mods.end(); }; const auto count = request.mods.size(); - const auto saved = [&](const char* one, const char* many) { + const auto saved = [&](const std::string& one, const std::string& many) { save(panel); if (!panel.message_error) - panel.message = (count == 1 ? std::string(one) : std::format("{} {}", count, many)) + + panel.message = (count == 1 ? one : std::format("{} {}", count, many)) + " Changes apply the next time Skate starts."; }; // The open overview follows its mod through a reorder. const std::string open = panel.selected >= 0 && panel.selected < static_cast(entries.size()) ? entries[static_cast(panel.selected)].mod.name : std::string(); + // Takes the asked mods out of the list and puts them back, in their order, at `at` (a place in + // what is left: 0 is the top). + const auto put = [&](auto&& at) { + std::vector moving, rest; + for (auto& entry : entries) (asked(entry) ? moving : rest).push_back(std::move(entry)); + const auto place = std::min(at(rest), rest.size()); + rest.insert(rest.begin() + static_cast(place), std::make_move_iterator(moving.begin()), + std::make_move_iterator(moving.end())); + entries = std::move(rest); + }; switch (request.kind) { case Request::Kind::enable: case Request::Kind::disable: { @@ -350,6 +542,22 @@ void carry_out(ModsPanel& panel, const thunderstore::Installed& installed, const save(panel); break; } + case Request::Kind::move: { + // (Dropped on one of the mods being moved: nothing to do.) + if (std::ranges::find(request.mods, request.target) != request.mods.end()) break; + const auto target = title_of(panel, request.target); + put([&](const std::vector& rest) { + const auto found = std::ranges::find_if(rest, [&](const mods::ModEntry& entry) { return entry.mod.name == request.target; }); + return static_cast(found - rest.begin()) + (found != rest.end() && !request.above ? 1 : 0); + }); + saved(std::format("Moved {} {} {}.", title_of(panel, request.mods.front()), request.above ? "above" : "below", target), + std::format("mods moved {} {}.", request.above ? "above" : "below", target)); + break; + } + case Request::Kind::place: + put([&](const std::vector&) { return static_cast(std::max(request.position, 1) - 1); }); + saved(std::format("Moved to position {}.", std::max(request.position, 1)), "mods moved."); + break; case Request::Kind::update: { std::vector packages; for (const auto& name : request.mods) @@ -368,9 +576,48 @@ void carry_out(ModsPanel& panel, const thunderstore::Installed& installed, const if (entries[i].mod.name == open) panel.selected = static_cast(i); } -// One installed mod. The tick on the left picks it for the bar above the list, -// the switch on the right is whether it loads, and a click anywhere else opens -// everything the mod says about itself. +// Where the table's columns are: from a row's left edge on the left, and the widths of those +// at its right, which are laid out from the right edge in. +struct Columns { + float tick = S(12), grip = S(44), place = S(64), on = S(108), icon = S(162), name = S(202); + float more = S(28), status = S(156), type = S(92), update = S(84); +}; +// Where the right-hand columns of a row `width` wide start, from its left edge. +struct RightColumns { + float more, status, type, update; +}; +RightColumns right_columns(float width) { + const Columns c; + RightColumns r{}; + r.more = width - S(10) - c.more; + r.status = r.more - S(12) - c.status; + r.type = r.status - S(12) - c.type; + r.update = r.type - S(10) - c.update; + return r; +} + +// The labels over the table's columns. +void table_header(const Fonts& fonts) { + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const Columns c; + const auto r = right_columns(width); + const auto label = [&](float x, const char* text) { + draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(start.x + x, start.y), color::muted, text); + }; + label(c.place, "#"); + label(c.on, "ON"); + label(c.icon, "MOD"); + label(r.type, "TYPE"); + label(r.status, "STATUS"); + ImGui::Dummy(ImVec2(width, fonts.caption->FontSize + S(6))); +} + +// One installed mod, a row of the table. The tick on the left picks it for the bar above the +// list, the handle beside it drags it to another place in the load order, its number moves it to +// a place typed in, the switch is whether it loads, and a click anywhere else opens everything +// the mod says about itself. void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, const std::vector& view, int position, float tall, bool reorder, Request& request) { const int index = view[static_cast(position)]; @@ -394,80 +641,148 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins panel.overview = false; } } - const float right = start.x + width; - const bool left_out = panel.list.excluded.contains(mod.name); - const bool conflicted = left_out && !conflict_winner(panel, mod.name).empty(); const auto* package = package_for(panel.store, mod.name); const bool update = package && thunderstore::update_available(*package, installed); + const Columns c; + const auto r = right_columns(width); + const float middle = start.y + tall * 0.5f; - ImGui::SetCursorScreenPos(ImVec2(start.x + S(14), start.y + (tall - ImGui::GetFrameHeight()) * 0.5f)); + ImGui::SetCursorScreenPos(ImVec2(start.x + c.tick, middle - ImGui::GetFrameHeight() * 0.5f)); bool tick = ticked; if (ImGui::Checkbox("##pick", &tick)) mark(panel, view, position, tick, ImGui::GetIO().KeyShift); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Tick mods to change several at once.\nShift-click ticks every mod in between."); - // Right to left: the menu, whether it loads, its place in the load order. - const float more = S(30); - const float more_x = right - S(12) - more; - ImGui::SetCursorScreenPos(ImVec2(more_x, start.y + (tall - more) * 0.5f)); - if (more_button("##more", more)) menu = true; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("More"); + // The handle: six dots, brighter while it can be grabbed. + { + const ImVec2 at(start.x + c.grip, start.y); + ImGui::SetCursorScreenPos(at); + ImGui::InvisibleButton("##grip", ImVec2(S(16), tall)); + const bool hovered = ImGui::IsItemHovered(); + if (hovered && reorder) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll); + if (hovered && !ImGui::GetDragDropPayload()) + ImGui::SetTooltip("%s", reorder ? "Drag to move it in the load order. Ticked mods move together." + : "To drag mods into a new order, show the list in load order."); + // Dragged, the row itself follows the pointer (installed_page draws it there): no tooltip. + if (reorder && ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceNoPreviewTooltip)) { + ImGui::SetDragDropPayload(dragged_row, &index, sizeof index); + if (panel.drag_name != mod.name) { + panel.drag_name = mod.name; + panel.drag_grab = ImGui::GetMousePos().y - start.y; + } + ImGui::EndDragDropSource(); + } + const ImU32 dots = !reorder ? rgba(255, 255, 255, 0.12f) : hovered ? color::text : color::muted; + for (int row = -1; row <= 1; ++row) + for (int column = 0; column < 2; ++column) + draw->AddCircleFilled(ImVec2(at.x + S(5) + column * S(6), middle + row * S(6)), S(1.6f), dots); + } - const float switch_x = more_x - S(12) - S(42); - ImGui::SetCursorScreenPos(ImVec2(switch_x, start.y + (tall - S(22)) * 0.5f)); + // Its place in the load order, whatever order the list is shown in: click to type another. + { + ImGui::SetCursorScreenPos(ImVec2(start.x + c.place, start.y)); + if (ImGui::InvisibleButton("##place", ImVec2(S(40), tall)) && reorder) { + panel.place = index + 1; + ImGui::OpenPopup("##place_popup"); + } + const bool hovered = ImGui::IsItemHovered(); + if (hovered) + ImGui::SetTooltip("%s", reorder ? "Its place in the load order. Click to move it to another." + : "Its place in the load order."); + const auto number = std::to_string(index + 1); + draw->AddText(fonts.body, fonts.body->FontSize, + ImVec2(start.x + c.place, middle - fonts.body->FontSize * 0.5f), hovered && reorder ? color::text : color::muted, + number.c_str()); + if (ImGui::BeginPopup("##place_popup")) { + ImGui::TextUnformatted("Move to position"); + ImGui::SetNextItemWidth(S(110)); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + const bool entered = ImGui::InputInt("##position", &panel.place, 0, 0, ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (ImGui::Button("Move") || entered) { + request = {Request::Kind::place, {mod.name}, {}, false, + std::clamp(panel.place, 1, static_cast(panel.list.entries.size()))}; + ImGui::CloseCurrentPopup(); + } + ImGui::TextDisabled("1 is the top: it wins over every mod below it."); + ImGui::EndPopup(); + } + } + + ImGui::SetCursorScreenPos(ImVec2(start.x + c.on, middle - S(11))); bool enabled = entry.enabled; if (toggle("##enabled", &enabled)) request = {enabled ? Request::Kind::enable : Request::Kind::disable, {mod.name}}; if (ImGui::IsItemHovered()) ImGui::SetTooltip(entry.enabled ? "Enabled: loads when Skate starts" : "Disabled: stays installed, does not load"); - const float arrow = ImGui::GetFrameHeight(); - const float arrows_x = switch_x - S(18) - arrow * 2 - S(6); - const char* fixed = "To move mods, show all of them in load order, with no update waiting (those are listed first)"; - ImGui::SetCursorScreenPos(ImVec2(arrows_x, start.y + (tall - arrow) * 0.5f)); - ImGui::BeginDisabled(!reorder || index == 0); - if (ImGui::ArrowButton("##up", ImGuiDir_Up)) request = {Request::Kind::up, {mod.name}}; - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("%s", reorder ? "Load earlier: where two mods change the same thing, the higher one wins" : fixed); - ImGui::SameLine(0, S(6)); - ImGui::BeginDisabled(!reorder || index + 1 == static_cast(panel.list.entries.size())); - if (ImGui::ArrowButton("##down", ImGuiDir_Down)) request = {Request::Kind::down, {mod.name}}; - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", reorder ? "Load later" : fixed); + // The same icon the store shows, so a mod looks like itself on both pages. + mod_icon(panel, package, ImVec2(start.x + c.icon, middle - S(15)), S(30)); - float next = arrows_x - S(14); + // Right to left: the menu, how it stands, what it is, and an update waiting for it. + ImGui::SetCursorScreenPos(ImVec2(start.x + r.more, middle - c.more * 0.5f)); + if (more_button("##more", c.more)) menu = true; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("More"); + + float name_end = start.x + r.type - S(12); if (update) { - const float button = S(96); - next -= button; - ImGui::SetCursorScreenPos(ImVec2(next, start.y + (tall - S(30)) * 0.5f)); + ImGui::SetCursorScreenPos(ImVec2(start.x + r.update, middle - S(14))); push_primary_button(); - if (ImGui::Button("UPDATE", ImVec2(button, S(30)))) request = {Request::Kind::update, {mod.name}}; + if (ImGui::Button("UPDATE", ImVec2(c.update, S(28)))) request = {Request::Kind::update, {mod.name}}; pop_primary_button(); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Thunderstore has v%s", package->latest().number.c_str()); - next -= S(14); + name_end = start.x + r.update - S(12); } - const float pill_y = start.y + (tall - fonts.caption->FontSize - S(8)) * 0.5f; - const auto pill = [&](const char* label, ImU32 fill) { - next -= badge_width(fonts, label); - badge(draw, fonts, ImVec2(next, pill_y), label, fill, color::ink); - next -= S(12); - }; - if (!mod.outdated.empty()) pill("OUTDATED", color::warning); - else if (conflicted) pill("CONFLICT", color::warning); - else if (left_out) pill("NOT LOADED", color::danger); + const auto kind = kind_of(panel, mod); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(start.x + r.type, middle - fonts.body->FontSize * 0.5f), + color::muted, kind.c_str()); - // The same icon the store shows, so a mod looks like itself on both pages. - mod_icon(panel, package, ImVec2(start.x + S(48), start.y + (tall - S(48)) * 0.5f), S(48)); - const float text_x = start.x + S(108); - const ImVec4 clip(text_x, start.y, next, start.y + tall); - // The number is its place in the load order, whatever order the list is shown in. - const auto title = std::to_string(index + 1) + ". " + mod.title; - draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, start.y + S(13)), - entry.enabled ? color::text : color::muted, title.c_str(), nullptr, 0, &clip); - if (const auto detail = summary(panel, mod); !detail.empty()) - draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, start.y + S(37)), color::muted, detail.c_str(), - nullptr, 0, &clip); + // How it stands: a pill, and on hover why. + { + const ImVec2 at(start.x + r.status, middle - (fonts.caption->FontSize + S(8)) * 0.5f); + std::string label, tip; + ImU32 fill{}; + const auto winner = conflict_winner(panel, mod.name); + if (!mod.outdated.empty()) { + label = "OUTDATED", fill = color::warning; + tip = "Made for another version of Skate (" + mod.outdated + "): it does not load."; + } else if (!entry.enabled) { + label = "OFF"; + tip = "Switched off: stays installed, does not load."; + } else if (!winner.empty()) { + const bool chosen = left_out_by_choice(panel, mod.name); + label = chosen ? "LEFT OUT" : "CONFLICT", fill = chosen ? color::tile_grey : color::warning; + const auto reason = conflict_reason(panel, mod.name); + tip = (chosen ? "Left out: you chose " + title_of(panel, winner) + " to load instead." + : "Left out: it loses to " + title_of(panel, winner) + ", which is higher in the load order.") + + (reason.empty() ? std::string() : " They " + reason + ".") + + (chosen ? "" : " Resolve conflicts to choose which loads."); + } else if (broken(panel, mod.name)) { + label = "NOT LOADED", fill = color::danger; + tip = "The game could not merge it cleanly, so none of it is used. Open it to see why."; + } + if (!label.empty() && fill) badge(draw, fonts, at, label, fill, fill == color::tile_grey ? color::text : color::ink); + else if (!label.empty()) + draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(at.x, at.y + S(4)), color::muted, label.c_str()); + if (!tip.empty() && ImGui::IsWindowHovered() && + ImGui::IsMouseHoveringRect(ImVec2(start.x + r.status, start.y), ImVec2(start.x + r.more, start.y + tall))) + ImGui::SetTooltip("%s", tip.c_str()); + } + + // The name, and who made it. + { + const float x = start.x + c.name; + const ImVec4 clip(x, start.y, name_end, start.y + tall); + const float y = middle - fonts.bold->FontSize * 0.5f; + draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(x, y), entry.enabled ? color::text : color::muted, + mod.title.c_str(), nullptr, 0, &clip); + if (!mod.author.empty()) { + const float after = x + fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, 0, mod.title.c_str()).x + S(10); + const auto by = "by " + mod.author + (mod.version.empty() ? std::string() : " v" + mod.version); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(after, middle - fonts.body->FontSize * 0.5f), + color::muted, by.c_str(), nullptr, 0, &clip); + } + } end_row(); if (menu) ImGui::OpenPopup("##menu"); @@ -494,6 +809,11 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins [&](const std::string& name) { return update_waiting(panel, installed, name); }) && ImGui::MenuItem("Update")) ask(Request::Kind::update); ImGui::Separator(); + if (!group) { + if (ImGui::MenuItem("Move up", nullptr, false, index > 0)) ask(Request::Kind::up); + if (ImGui::MenuItem("Move down", nullptr, false, index + 1 < static_cast(panel.list.entries.size()))) + ask(Request::Kind::down); + } if (ImGui::MenuItem("Move to top")) ask(Request::Kind::top); if (ImGui::MenuItem("Move to bottom")) ask(Request::Kind::bottom); if (!group) { @@ -511,19 +831,237 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins ImGui::PopID(); } +// Over the list: whether conflicts wait for the player, with the button that opens the popup to +// settle them; once all are settled, a quiet line to look at them again. +void conflicts_bar(const Fonts& fonts, ModsPanel& panel) { + const auto& check = panel.conflicts; + const bool checking = check.running || check.asked != check.checked; + if (!check.ready) { + if (checking) ImGui::TextDisabled("Checking your mods for conflicts..."); + return; + } + const auto pairs = conflict_pairs(panel); + if (pairs.empty()) return; + const auto open = static_cast(std::count_if(pairs.begin(), pairs.end(), + [&](const ConflictPair& pair) { return !settled(panel, pair); })); + if (!open) { + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", std::format("{} {} resolved: the mods you chose load.", pairs.size(), + pairs.size() == 1 ? "conflict" : "conflicts").c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("Review")) panel.resolving = true; + return; + } + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(rgba(255, 190, 60, 0.07f))); + ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(rgba(255, 190, 60, 0.35f))); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, S(6)); + ImGui::BeginChild("##conflicts", ImVec2(0, 0), + ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding | ImGuiChildFlags_NavFlattened); + const float button = S(190); + const float top = ImGui::GetCursorPosY(); + ImGui::PushTextWrapPos(ImGui::GetContentRegionMax().x - button - S(16)); + ImGui::PushFont(fonts.bold); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), "%s", + std::format("{} {} TO RESOLVE", open, open == 1 ? "CONFLICT" : "CONFLICTS").c_str()); + ImGui::PopFont(); + ImGui::TextDisabled("Some of your mods change the same parts of the game in ways that can't be combined, so only one " + "of each pair can load. Choose which."); + ImGui::PopTextWrapPos(); + const float bottom = ImGui::GetCursorPosY(); + ImGui::SetCursorPos(ImVec2(ImGui::GetContentRegionMax().x - button, top + (bottom - top - S(34)) * 0.5f)); + push_primary_button(); + if (ImGui::Button("RESOLVE CONFLICTS", ImVec2(button, S(34)))) panel.resolving = true; + pop_primary_button(); + ImGui::SetCursorPosY(std::max(bottom, top + S(34))); + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(2); +} + +// Mods that clash over the same part of the game, as the popup shows them: every mod linked to +// another by a conflict, whichever way it came out, with what they change. +struct ConflictGroup { + std::vector mods; + std::vector areas; + bool fresh{}; // a conflict in it the player has not settled +}; +std::vector conflict_groups(const ModsPanel& panel) { + const auto& check = panel.conflicts; + std::map> parent; + const auto root = [&](std::string name) { + while (parent.contains(name) && parent.at(name) != name) name = parent.at(name); + return name; + }; + const auto enabled = [&](const std::string& name) { return std::ranges::find(check.order, name) != check.order.end(); }; + for (const auto& row : check.rows) { + if (!enabled(row.kept) || !enabled(row.lost)) continue; + parent.try_emplace(row.kept, row.kept); + parent.try_emplace(row.lost, row.lost); + if (const auto a = root(row.kept), b = root(row.lost); a != b) parent[b] = a; + } + std::map groups; + for (const auto& name : check.order) + if (parent.contains(name)) groups[root(name)].mods.push_back(name); + for (const auto& row : check.rows) { + if (!parent.contains(row.kept)) continue; + auto& group = groups[root(row.kept)]; + for (const auto& area : row.areas) + if (std::ranges::find(group.areas, area) == group.areas.end()) group.areas.push_back(area); + } + for (const auto& pair : conflict_pairs(panel)) + if (!settled(panel, pair) && parent.contains(pair.left_out)) groups[root(pair.left_out)].fresh = true; + std::vector result; + for (auto& [first, group] : groups) result.push_back(std::move(group)); + return result; +} + +// The popup that settles them: each clash as a card with its mods, a row each in an order that +// stays put while the popup is open, saying which loads and which is left out. "Load this one" +// moves a left-out mod above the others it clashes with; only the words beside the mods change. +// What the player leaves as it is counts as chosen (Done), so the list asks again only about a +// conflict that is new or has come out the other way since. +void resolve_popup(const Fonts& fonts, ModsPanel& panel, Request& request) { + if (panel.resolving) { + panel.resolve_order = panel.conflicts.order; + ImGui::OpenPopup("##resolve_conflicts"); + panel.resolving = false; + } + const auto* viewport = ImGui::GetMainViewport(); + const ImVec2 extent(std::min(S(860), viewport->Size.x - S(80)), std::min(S(680), viewport->Size.y - S(60))); + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + (viewport->Size.x - extent.x) * 0.5f, + viewport->Pos.y + (viewport->Size.y - extent.y) * 0.5f)); + ImGui::SetNextWindowSize(extent); + if (!ImGui::BeginPopupModal("##resolve_conflicts", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings)) + return; + // Where each mod stood when the popup opened: rows and cards keep that order. + const auto place = [&](const std::string& name) { + const auto found = std::ranges::find(panel.resolve_order, name); + return static_cast(found - panel.resolve_order.begin()); + }; + auto groups = conflict_groups(panel); + for (auto& group : groups) + std::ranges::sort(group.mods, [&](const std::string& a, const std::string& b) { return place(a) < place(b); }); + std::ranges::sort(groups, [&](const ConflictGroup& a, const ConflictGroup& b) { return place(a.mods.front()) < place(b.mods.front()); }); + const bool checking = panel.conflicts.running || panel.conflicts.asked != panel.conflicts.checked; + + ImGui::PushFont(fonts.heading); + ImGui::TextUnformatted("RESOLVE CONFLICTS"); + ImGui::PopFont(); + ImGui::PushTextWrapPos(0); + ImGui::TextDisabled("Each card is mods that change the same part of the game in ways that can't be combined. " + "Pick the one you want with Load this one: it moves above the others, and they are left out. " + "Your choice is kept until the load order changes."); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + + const float footer = ImGui::GetFrameHeight() + S(24); + ImGui::BeginChild("##resolve_list", ImVec2(0, ImGui::GetContentRegionAvail().y - footer), ImGuiChildFlags_NavFlattened); + if (groups.empty()) + ImGui::TextDisabled("%s", checking ? "Checking again..." : "No conflicts between your enabled mods."); + auto* draw = ImGui::GetWindowDrawList(); + const float pad = S(14), row = S(40), head = S(30); + for (const auto& group : groups) { + ImGui::PushID(group.mods.front().c_str()); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const float tall = pad * 2 + head + row * static_cast(group.mods.size()); + card(draw, start, ImVec2(width, tall)); + float x = start.x + pad; + if (group.fresh) { + badge(draw, fonts, ImVec2(x, start.y + pad), "NEW", color::warning, color::ink); + x += badge_width(fonts, "NEW") + S(10); + } + std::string areas; + for (std::size_t at = 0; at < group.areas.size(); ++at) + areas += (at == 0 ? "" : at + 1 == group.areas.size() ? " and " : ", ") + group.areas[at]; + const auto heading = "They clash over " + (areas.empty() ? std::string("the same game files") : areas) + + (group.mods.size() == 2 ? ": only one can load." : ": only mods whose changes agree can load together."); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(x, start.y + pad + S(1)), color::muted, heading.c_str()); + for (std::size_t at = 0; at < group.mods.size(); ++at) { + const auto& name = group.mods[at]; + ImGui::PushID(name.c_str()); + const float y = start.y + pad + head + row * static_cast(at); + const float middle = y + row * 0.5f; + const bool loads = !panel.conflicts.losses.contains(name); + if (at > 0) + draw->AddLine(ImVec2(start.x + pad, y), ImVec2(start.x + width - pad, y), color::outline); + const char* standing = loads ? "LOADS" : "LEFT OUT"; + badge(draw, fonts, ImVec2(start.x + pad, middle - (fonts.caption->FontSize + S(8)) * 0.5f), standing, + loads ? color::good : color::tile_grey, loads ? color::ink : color::text); + const float name_x = start.x + pad + badge_width(fonts, "LEFT OUT") + S(14); + const float button = S(150); + const ImVec4 clip(name_x, y, start.x + width - pad - button - S(12), y + row); + draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(name_x, middle - fonts.bold->FontSize * 0.5f), + loads ? color::text : color::muted, title_of(panel, name).c_str(), nullptr, 0, &clip); + if (!loads) { + ImGui::SetCursorScreenPos(ImVec2(start.x + width - pad - button, middle - S(15))); + if (ImGui::Button("Load this one", ImVec2(button, S(30)))) { + // Above the highest of the others it clashes with; settled as chosen over each. + std::string highest; + for (const auto& other : panel.conflicts.order) { + if (other == name) break; + const bool clash = std::ranges::any_of(panel.conflicts.rows, [&](const mods::MergeReport::Conflict& r) { + return (r.kept == other && r.lost == name) || (r.kept == name && r.lost == other); }); + if (clash) { + highest = other; + break; + } + } + for (const auto& r : panel.conflicts.rows) { + const auto& other = r.kept == name ? r.lost : r.lost == name ? r.kept : std::string(); + if (other.empty()) continue; + panel.conflict_choices.erase({other, name}); + panel.conflict_choices.emplace(name, other); + } + write_conflict_choices(panel); + if (!highest.empty()) request = {Request::Kind::move, {name}, highest, true}; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", ("Move " + title_of(panel, name) + " above the mods here it clashes with, so it " + "loads and they are left out").c_str()); + } + ImGui::PopID(); + } + ImGui::SetCursorScreenPos(ImVec2(start.x, start.y + tall + S(10))); + ImGui::Dummy(ImVec2(width, 0)); + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(16) - ImGui::GetFrameHeight()); + if (checking) { + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("Checking again..."); + ImGui::SameLine(); + } + ImGui::SetCursorPosX(ImGui::GetWindowWidth() - S(24) - S(120)); + // Done keeps every conflict as it stands now: what the player saw and left is their choice. + if (ImGui::Button("DONE", ImVec2(S(120), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + // (Not a pair the player just turned round, which the list has not checked again yet.) + for (const auto& pair : conflict_pairs(panel)) + if (!panel.conflict_choices.contains({pair.left_out, pair.loads})) panel.conflict_choices.emplace(pair.loads, pair.left_out); + write_conflict_choices(panel); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, float height, bool installing) { auto& entries = panel.list.entries; const float top = ImGui::GetCursorPosY(); { const auto enabled = std::count_if(entries.begin(), entries.end(), [](const auto& entry) { return entry.enabled; }); - // A mod left out for a conflict is no fault of its own: counted apart from the problems. + // A mod left out for a conflict is no fault of its own: counted apart from the problems, and + // only while the player has not settled it (Resolve conflicts). const auto conflicts = std::count_if(entries.begin(), entries.end(), [&](const auto& entry) { - return entry.enabled && entry.mod.outdated.empty() && !conflict_winner(panel, entry.mod.name).empty(); + return entry.enabled && entry.mod.outdated.empty() && !conflict_winner(panel, entry.mod.name).empty() && + !left_out_by_choice(panel, entry.mod.name); }); const auto problems = std::count_if(entries.begin(), entries.end(), [&](const auto& entry) { - return !entry.mod.outdated.empty() || (entry.enabled && panel.list.excluded.contains(entry.mod.name)); - }) - conflicts; + return !entry.mod.outdated.empty() || (entry.enabled && broken(panel, entry.mod.name)); + }); const auto waiting = std::count_if(entries.begin(), entries.end(), [&](const auto& entry) { return update_waiting(panel, installed, entry.mod.name); }); std::uint64_t bytes{}; @@ -532,7 +1070,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co if (bytes) caption += " / " + size_text(bytes); page_banner(fonts, "MY MODS", nullptr, "Your installed mods. They load top to bottom: where two change the same thing, the higher one wins. " - "Changes apply the next time Skate starts.", + "Drag a mod by its handle to move it. Changes apply the next time Skate starts.", problems ? std::format("{} {}", problems, problems == 1 ? "PROBLEM" : "PROBLEMS") : conflicts ? std::format("{} {}", conflicts, conflicts == 1 ? "CONFLICT" : "CONFLICTS") : waiting ? std::format("{} {} WAITING", waiting, waiting == 1 ? "UPDATE" : "UPDATES") @@ -541,11 +1079,11 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co : entries.empty() ? color::tile_grey : color::good, caption); } - // ------------------------------------------------ search, filter, order, refresh - const float combo = S(170), refresh = S(110); - ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + S(10)) * 2 - refresh - S(10))); + // ------------------------------------------------ search, filter, order, check, refresh + const float combo = S(150), check = S(160), refresh = S(96), gap = S(10); + ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + gap) * 2 - check - gap - refresh - gap)); ImGui::InputTextWithHint("##installed_search", "Search your mods", panel.search.data(), panel.search.size()); - ImGui::SameLine(); + ImGui::SameLine(0, gap); const auto choice = [&](const char* id, const auto& names, int& value) { const int last = static_cast(names.size()) - 1; ImGui::SetNextItemWidth(combo); @@ -556,26 +1094,34 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co } }; choice("##installed_filter", filter_names, panel.filter); - ImGui::SameLine(); + ImGui::SameLine(0, gap); choice("##installed_order", order_names, panel.order); - ImGui::SameLine(); + ImGui::SameLine(0, gap); + { + const bool checking = panel.conflicts.running || panel.conflicts.asked != panel.conflicts.checked; + ImGui::BeginDisabled(checking); + if (ImGui::Button(checking ? "Checking..." : "Check conflicts", ImVec2(check, 0))) want_conflict_check(panel, 0); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("Checks your enabled mods, in this order, for mods that change the same parts of the game. " + "The list checks itself after every change too."); + } + ImGui::SameLine(0, gap); if (ImGui::Button("Refresh", ImVec2(refresh, 0))) refresh_mods(launcher, panel); const auto view = visible_mods(panel, installed); - // Arrows move a mod past its neighbour in the load order, which is only - // what the list shows when nothing is filtered out or sorted another way. - // (Mods moved to the top for their updates are out of that order too.) - const bool in_load_order = [&] { - for (int at = 0; at < static_cast(view.size()); ++at) - if (view[static_cast(at)] != at) return false; - return true; - }(); - const bool reorder = panel.order == 0 && view.size() == entries.size() && in_load_order; + // Dragging, and typing a place, move a mod among the others in the load order: the order the + // list must be shown in for where it lands to be what it looks like. + const bool reorder = panel.order == 0 && !installing; const auto shown = [&](int at) -> const std::string& { return entries[static_cast(view[static_cast(at)])].mod.name; }; Request request; + // ------------------------------------------------ the conflicts, and the way to settle them + ImGui::Spacing(); + conflicts_bar(fonts, panel); + // ------------------------------------------------ tick all, and what to do with the ticked ImGui::Spacing(); ImGui::BeginDisabled(installing); @@ -617,10 +1163,10 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co Action{"Move to bottom", S(132), Request::Kind::bottom}, Action{update_label.c_str(), S(104), Request::Kind::update}, Action{"Uninstall", S(96), Request::Kind::uninstall}}; - const float gap = S(8), clear = S(70); + const float action_gap = S(8), clear = S(70); float across = clear; for (const auto& action : actions) - if (action.kind != Request::Kind::update || waiting) across += action.width + gap; + if (action.kind != Request::Kind::update || waiting) across += action.width + action_gap; ImGui::SameLine(); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetContentRegionMax().x - across)); for (const auto& action : actions) { @@ -631,13 +1177,13 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co if (ImGui::Button(action.label, ImVec2(action.width, 0))) request = {action.kind, names}; if (danger) ImGui::PopStyleColor(); if (primary) pop_primary_button(); - ImGui::SameLine(0, gap); + ImGui::SameLine(0, action_gap); } if (ImGui::Button("Clear", ImVec2(clear, 0))) panel.marked.clear(); } ImGui::EndDisabled(); - // ------------------------------------------------ what the player should know + // ------------------------------------------------ what else the player should know ImGui::PushTextWrapPos(0); if (launcher.game()) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), @@ -646,29 +1192,9 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), "mods.json could not be read (%s), so the game loads no mods. Any change here rewrites it.", panel.list.issue.c_str()); - std::vector dropped, conflicted; - std::string winner; // with one conflicted mod: the mod it lost to - for (const auto& entry : entries) { - if (!entry.enabled || !panel.list.excluded.contains(entry.mod.name) || !entry.mod.outdated.empty()) continue; - if (auto other = conflict_winner(panel, entry.mod.name); !other.empty()) { - conflicted.push_back(entry.mod.title); - winner = title_of(panel, other); - } else { - dropped.push_back(entry.mod.title); - } - } - if (!conflicted.empty()) { - std::string names; - for (const auto& title : conflicted) names += (names.empty() ? "" : ", ") + title; - if (conflicted.size() == 1) - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), - "%s did not load: it conflicts with %s, which is higher in the load order, so only that one loads. " - "Open it to see more.", names.c_str(), winner.c_str()); - else - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), - "%s did not load: each conflicts with a mod higher in the load order, so only that one loads. " - "Open one to see which.", names.c_str()); - } + std::vector dropped; + for (const auto& entry : entries) + if (entry.enabled && entry.mod.outdated.empty() && broken(panel, entry.mod.name)) dropped.push_back(entry.mod.title); if (!dropped.empty()) { std::string names; for (const auto& title : dropped) names += (names.empty() ? "" : ", ") + title; @@ -686,7 +1212,8 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co ImGui::PopTextWrapPos(); ImGui::Spacing(); - // ------------------------------------------------ the list, and under it how its order works + // ------------------------------------------------ the table + table_header(fonts); const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top)); ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_NavFlattened); const auto note = [](const char* first, const char* second = nullptr) { @@ -700,12 +1227,92 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co note(panel.list.present ? "No mods installed yet." : "No Mods folder yet.", "Use GET MODS to browse Thunderstore, or drop a mod .zip on the window."); else if (view.empty()) note("No mods match."); - const float row = S(72) + row_gap(); + const float row = S(46) + row_gap(); + const ImVec2 origin = ImGui::GetCursorPos(); + const ImVec2 origin_screen = ImGui::GetCursorScreenPos(); + // A row dragged by its handle: the mods it moves, lifted out of the list and following the + // pointer, and where among the rest they would land (slot). + const auto* carried = ImGui::GetDragDropPayload(); + const bool dragging = reorder && carried && carried->IsDataType(dragged_row) && !panel.drag_name.empty(); + if (!dragging) panel.drag_name.clear(); + std::vector moving; + if (dragging) moving = dragged_mods(panel, *static_cast(carried->Data)); + const auto lifted = [&](int index) { + return std::ranges::find(moving, entries[static_cast(index)].mod.name) != moving.end(); + }; + std::vector rest, raised; + for (const int index : view) (dragging && lifted(index) ? raised : rest).push_back(index); + const float held = ImGui::GetMousePos().y - origin_screen.y - panel.drag_grab; // the dragged row's top + const auto slot = static_cast(std::clamp(static_cast(std::floor(held / row + 0.5f)), 0, + static_cast(rest.size()))); + // Where each row goes: in view order, or with a drag on, the rest with a gap at the slot. + std::vector> places; // (index, height) + if (!dragging || raised.empty()) { + for (std::size_t at = 0; at < view.size(); ++at) places.emplace_back(view[at], static_cast(at) * row); + } else { + for (std::size_t at = 0; at < rest.size(); ++at) + places.emplace_back(rest[at], static_cast(at < slot ? at : at + raised.size()) * row); + } + // Eased toward it: a row moved slides there. One never drawn before starts where it goes. + const float ease = std::min(1.0f, ImGui::GetIO().DeltaTime * 16.0f); + const auto eased = [&](const std::string& name, float target, bool follow) { + auto [at, fresh] = panel.row_y.try_emplace(name, target); + float& y = at->second; + if (follow || fresh || std::abs(target - y) < 0.5f) y = target; + else y += (target - y) * ease; + return y; + }; + std::map position_of; // place in the view, for ticking a run of rows + for (std::size_t at = 0; at < view.size(); ++at) position_of[view[at]] = static_cast(at); + const float scroll = ImGui::GetScrollY(), seen = ImGui::GetWindowHeight(); ImGui::BeginDisabled(installing); - virtual_rows(static_cast(view.size()), [&](int) { return row; }, [&](int position, float slot) { - installed_row(fonts, panel, installed, view, position, slot - row_gap(), reorder, request); - }); + for (const auto& [index, target] : places) { + const float y = eased(entries[static_cast(index)].mod.name, target, false); + // Only the rows in sight, and one past each edge (a controller moving off the last one needs the next). + if (y + 2 * row < scroll || y > scroll + seen + row) continue; + ImGui::SetCursorPos(ImVec2(origin.x, origin.y + y)); + installed_row(fonts, panel, installed, view, position_of[index], row - row_gap(), reorder, request); + } + // The dragged rows last, over the rest, where the pointer holds them, edged in blue. + for (std::size_t at = 0; dragging && at < raised.size(); ++at) { + const int index = raised[at]; + const float y = eased(entries[static_cast(index)].mod.name, held + static_cast(at) * row, true); + ImGui::SetCursorPos(ImVec2(origin.x, origin.y + y)); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + auto* draw = ImGui::GetWindowDrawList(); + draw->AddRectFilled(ImVec2(start.x + S(4), start.y + S(6)), ImVec2(start.x + width + S(4), start.y + row - row_gap() + S(6)), + rgba(0, 0, 0, 0.35f), S(6)); + installed_row(fonts, panel, installed, view, position_of[index], row - row_gap(), reorder, request); + draw->AddRect(start, ImVec2(start.x + width, start.y + row - row_gap()), color::blue, S(6), 0, S(2)); + } + // So the list scrolls over every row and no further. + ImGui::SetCursorPos(ImVec2(origin.x, origin.y + static_cast(view.size()) * row)); + ImGui::Dummy(ImVec2(1, 0)); ImGui::EndDisabled(); + // Dropped anywhere on the list: the dragged mods go where the gap is, above the row after it + // (or below the last). + if (dragging && !rest.empty()) { + const ImRect area(ImGui::GetWindowPos(), ImVec2(ImGui::GetWindowPos().x + ImGui::GetWindowWidth(), + ImGui::GetWindowPos().y + ImGui::GetWindowHeight())); + if (ImGui::BeginDragDropTargetCustom(area, ImGui::GetID("##drop"))) { + if (const auto* delivered = ImGui::AcceptDragDropPayload(dragged_row, + ImGuiDragDropFlags_AcceptBeforeDelivery | ImGuiDragDropFlags_AcceptNoDrawDefaultRect); + delivered && delivered->IsDelivery()) { + const bool last = slot >= rest.size(); + request = {Request::Kind::move, moving, entries[static_cast(last ? rest.back() : rest[slot])].mod.name, !last}; + } + ImGui::EndDragDropTarget(); + } + } + // While a row is dragged near the list's top or bottom edge, the list scrolls that way. + if (const auto* payload = ImGui::GetDragDropPayload(); payload && payload->IsDataType(dragged_row)) { + const float mouse = ImGui::GetMousePos().y, edge = S(56); + const float top_edge = ImGui::GetWindowPos().y, bottom_edge = top_edge + ImGui::GetWindowHeight(); + const float speed = S(900) * ImGui::GetIO().DeltaTime; + if (mouse < top_edge + edge) ImGui::SetScrollY(ImGui::GetScrollY() - speed * (top_edge + edge - mouse) / edge); + else if (mouse > bottom_edge - edge) ImGui::SetScrollY(ImGui::GetScrollY() + speed * (mouse - (bottom_edge - edge)) / edge); + } keep_focus_in_list(); ImGui::EndChild(); @@ -716,6 +1323,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co if (!panel.marked.empty() && ImGui::IsKeyPressed(ImGuiKey_Delete, false)) request = {Request::Kind::uninstall, std::vector(panel.marked.begin(), panel.marked.end())}; } + resolve_popup(fonts, panel, request); if (request.kind != Request::Kind::none) carry_out(panel, installed, request); } @@ -804,15 +1412,24 @@ void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Inst ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), "Outdated: this mod does not load. It was made for another version of Skate (%s). Get an updated " "version, or rebuild it with the latest ReSkate Studio.", mod.outdated.c_str()); - if (const auto other = conflict_winner(panel, mod.name); !other.empty()) { + if (const auto other = conflict_winner(panel, mod.name); !other.empty() && mod.outdated.empty()) { const auto winner = title_of(panel, other); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), "Not loaded: this mod changes the same parts of the game as %s, which is higher in the load order, in a " "way the two cannot share, so only %s loads. To use this mod instead, move it above %s, or switch one " "of them off.", winner.c_str(), winner.c_str(), winner.c_str()); - if (const auto& problems = panel.list.excluded.at(mod.name); !problems.empty()) - ImGui::TextDisabled("%s", problems.front().c_str()); - } else if (const auto missing = panel.list.excluded.find(mod.name); missing != panel.list.excluded.end()) { + if (const auto reason = conflict_reason(panel, mod.name); !reason.empty()) + ImGui::TextDisabled("%s", ("They " + reason + ".").c_str()); + else if (const auto found = panel.list.excluded.find(mod.name); found != panel.list.excluded.end() && !found->second.empty()) + ImGui::TextDisabled("%s", found->second.front().c_str()); + } else if (const auto* beats = conflict_beats(panel, mod.name); beats && entry.enabled) { + std::string names; + for (std::size_t at = 0; at < beats->size(); ++at) + names += (at == 0 ? "" : at + 1 == beats->size() ? " and " : ", ") + title_of(panel, (*beats)[at]); + ImGui::TextDisabled("%s", ("Loads, and leaves out " + names + + ": they change the same parts of the game and are lower in the load order.").c_str()); + } + if (const auto missing = panel.list.excluded.find(mod.name); missing != panel.list.excluded.end() && broken(panel, mod.name)) { ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), "Not loaded: the game could not merge this mod cleanly, so none of it is used. Reinstall the whole " "mod folder, or rebuild it with a current ReSkate Studio."); @@ -937,6 +1554,20 @@ fs::path pick_path(HWND owner, bool folder, const wchar_t* title, const wchar_t* return result; } +void wait_for_conflict_check(const ModsPanel& panel) { + for (int waited = 0; panel.conflicts.running && waited < 500; ++waited) Sleep(20); +} + +void prepare_mods(ModsPanel& panel, const launcher_app::Session& session) { + if (panel.root.empty()) { + panel.root = launcher_mods::mods_root(session.paths.directory); + panel.list = mods::scan_mods(panel.root.parent_path()); + want_conflict_check(panel, 0); + read_conflict_choices(panel); + } + pump_conflict_check(panel); +} + void refresh_mods(Launcher& launcher, ModsPanel& panel) { scan(panel, launcher.session()); refresh_listing(panel, ImGui::GetTime(), true); @@ -945,7 +1576,15 @@ void refresh_mods(Launcher& launcher, ModsPanel& panel) { void scan(ModsPanel& panel, const launcher_app::Session& session) { panel.root = launcher_mods::mods_root(session.paths.directory); + // (Official mods kept where they were before they were mods like any other come into the list.) + for (const auto& line : mods::move_official_mods(panel.root)) + logging::write(logging::Level::info, logging::Channel::launcher, "Official mods: " + line); panel.list = mods::scan_mods(panel.root.parent_path()); + panel.officials.clear(); + for (const auto& entry : panel.list.entries) + if (mods::official_mod(entry.mod.directory)) panel.officials.insert(entry.mod.name); + want_conflict_check(panel, 0); + read_conflict_choices(panel); panel.sizes.clear(); for (const auto& entry : panel.list.entries) panel.sizes[entry.mod.name] = folder_size(entry.mod.directory); panel.scanned = true; @@ -971,6 +1610,7 @@ void start_install(ModsPanel& panel, const fs::path& source, bool replace) { const auto root = panel.root; panel.worker = std::thread([&panel, root, source, replace] { std::string name, error, conflict; + wait_for_conflict_check(panel); // (it may be reading the mod this replaces) try { name = launcher_mods::install(root, source, replace, [&panel](float fraction) { panel.progress = fraction; }, panel.cancel); @@ -995,25 +1635,16 @@ void start_install(ModsPanel& panel, const fs::path& source, bool replace) { void mods_broken_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel, const std::vector& problems) { const bool one = problems.size() == 1; - // A conflict is two mods changing the same game files: only the one higher in the load order loads. - const bool conflicts = std::all_of(problems.begin(), problems.end(), [](const ModProblem& problem) { return problem.conflict; }); const float rows = static_cast(problems.size()) * S(52); const auto frame = begin_panel("##mods_broken_panel", size, ImVec2(S(620), std::min(size.y - S(80), S(300) + rows))); - panel_title(fonts, conflicts ? (one ? "MOD CONFLICT" : "MOD CONFLICTS") : one ? "A MOD COULD NOT BE MERGED" : "MODS COULD NOT BE MERGED"); + panel_title(fonts, one ? "A MOD COULD NOT BE MERGED" : "MODS COULD NOT BE MERGED"); ImGui::PushTextWrapPos(0); - if (conflicts) - ImGui::TextDisabled(one - ? "This mod changes the same parts of the game as a mod above it in your load order, in a way the two " - "cannot share. Only the higher one loads, so this one is left out." - : "These mods change the same parts of the game as mods above them in your load order, in ways they " - "cannot share. Only the higher ones load, so these are left out."); - else - ImGui::TextDisabled(one - ? "This mod cannot be combined with the game's files, so none of its content would load. Skate would " - "start without it and never say why." - : "These mods cannot be combined with the game's files, so none of their content would load. Skate would " - "start without them and never say why."); + ImGui::TextDisabled(one + ? "This mod cannot be combined with the game's files, so none of its content would load. Skate would " + "start without it and never say why." + : "These mods cannot be combined with the game's files, so none of their content would load. Skate would " + "start without them and never say why."); ImGui::PopTextWrapPos(); ImGui::Spacing(); @@ -1022,7 +1653,7 @@ void mods_broken_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); for (const auto& problem : problems) { ImGui::PushFont(fonts.bold); - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(problem.conflict ? color::warning : color::danger)); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger)); ImGui::TextUnformatted(problem.title.c_str()); ImGui::PopStyleColor(); ImGui::PopFont(); @@ -1034,9 +1665,7 @@ void mods_broken_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ImGui::EndChild(); ImGui::Spacing(); ImGui::PushFont(fonts.caption); - ImGui::TextDisabled("%s", conflicts - ? "To use a left-out mod instead, move it above the other in the Mod Manager, or switch one of them off." - : "Reinstall the whole mod folder, or rebuild it with a current ReSkate Studio."); + ImGui::TextDisabled("Reinstall the whole mod folder, or rebuild it with a current ReSkate Studio."); ImGui::PopFont(); ImGui::SetCursorPosY(frame.y - S(24) - ImGui::GetFrameHeight()); @@ -1154,8 +1783,12 @@ void mods_outdated_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, U void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel, HWND window) { const auto& session = launcher.session(); + // MY MODS opened from another tab: what the OFFICIAL tab installed or took away is in the folder. + if (panel.scanned && panel.tab == 0 && panel.shown_tab != -1 && panel.shown_tab != 0) scan(panel, session); + panel.shown_tab = panel.tab; if (!panel.scanned) scan(panel, session); collect_install(panel, session); + pump_conflict_check(panel); settle_profile(panel); refresh_listing(panel, ImGui::GetTime()); // A page, not a panel: the mod list and the Thunderstore browser both want @@ -1262,6 +1895,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo // Stops at the first folder that will not go: what went is gone, the rest is untouched. std::vector removed; std::string failure; + wait_for_conflict_check(panel); // (it may be reading these mods' files; a second or two at most) for (const auto& name : names) { try { launcher_mods::remove(panel.root, name); diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 65fe2b69..68d8ca08 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -355,6 +355,7 @@ void start_store_install(ModsPanel& panel, std::vector pa const auto root = panel.root; panel.worker = std::thread([&panel, root, packages = std::move(packages)] { std::string name, error, note; + wait_for_conflict_check(panel); // (it may be reading the mods these replace) std::size_t done = 0; // One that fails does not stop the ones after it: each is its own download, and the // list ends with what could not be installed. Cancelling does stop the rest. @@ -597,8 +598,12 @@ void official_page(const Fonts& fonts, ModsPanel& panel, float height) { const float switch_x = pill_x + S(8), switch_y = pills_y + (fonts.caption->FontSize + S(8) - S(22)) * 0.5f; ImGui::SetCursorScreenPos(ImVec2(switch_x, switch_y)); if (toggle("##on", &on)) { - if (mods::set_official_off(panel.root, mod.full_name, !on)) + if (mods::set_official_off(panel.root, mod.full_name, !on)) { log(logging::Level::info, std::format("Official mods: {} turned {}", mod.full_name, on ? "on" : "off")); + // MY MODS holds the list as it read it: kept the same, so saving it there keeps this. + for (auto& entry : panel.list.entries) + if (entry.mod.name == mod.full_name) entry.enabled = on; + } else log(logging::Level::warning, std::format("Official mods: {} could not be turned {}", mod.full_name, on ? "on" : "off")); read_off(); @@ -635,7 +640,7 @@ void official_page(const Fonts& fonts, ModsPanel& panel, float height) { } if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its page on Thunderstore"); if (installed) { - const auto folder = panel.root / std::string(mods::official_folder) / wide(mod.full_name); + const auto folder = panel.root / wide(mod.full_name); ImGui::SetCursorScreenPos(ImVec2(buttons_x, button_y + S(40))); if (ImGui::Button("OPEN FOLDER", ImVec2(button, S(32)))) open_path(folder); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Where ReSkate keeps it"); diff --git a/Launcher/official_mods.cpp b/Launcher/official_mods.cpp index 850d75d1..7ede7d77 100644 --- a/Launcher/official_mods.cpp +++ b/Launcher/official_mods.cpp @@ -81,7 +81,19 @@ std::string title_of(std::string_view full_name) { 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); } +// Where the last list read is kept: the folder official mods were kept in before they were mods like +// any other (builtin_packs.h). +fs::path list_root(const fs::path& mods_root) { return mods_root / std::string(mods::official_folder); } +// Takes `names` out of mods.json, every other row as it was; a malformed one is left for the player to see. +void forget(const fs::path& mods_root, const std::vector& names) { + if (names.empty()) return; + auto list = mods::scan_mods(mods_root.parent_path()); + if (!list.issue.empty()) return; + std::erase_if(list.entries, [&](const mods::ModEntry& entry) { + return std::ranges::find(names, entry.mod.name) != names.end(); + }); + mods::save_mod_order(mods_root, list.entries); +} 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'; @@ -115,7 +127,8 @@ void ask_thunderstore(Mod& mod) { 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. +// Downloads `mod` and puts it in place of the version there (Mods/), whole or not +// at all: in the load order where it was, or at the bottom when it is new there. void download(const fs::path& root, const Mod& mod, const std::atomic& cancel) { const auto cache = root / L".reskate-download"; std::error_code error; @@ -134,22 +147,25 @@ void download(const fs::path& root, const Mod& mod, const std::atomic& can 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. + // Installed beside the download (out of the mod list's sight), then swapped in. const auto staging = mod.full_name + ".new"; - fs::remove_all(root / wide(staging), error); + fs::remove_all(cache / 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); + launcher_mods::install(cache, archive, false, {}, cancel, options); const auto target = root / wide(mod.full_name); + const bool fresh = !fs::exists(target, error); fs::remove_all(target, error); - fs::rename(root / wide(staging), target, error); + fs::rename(cache / wide(staging), target, error); if (error) { - fs::remove_all(root / wide(staging), error); + fs::remove_all(cache / wide(staging), error); throw std::runtime_error("the new version could not be put in place"); } write_stamp(target, mod.version); + // A new one goes at the bottom of the load order, where official mods always loaded. + if (fresh) (void)mods::set_mod_enabled(root, mod.full_name, true); } std::string status_of(const Mod& mod) { @@ -160,7 +176,9 @@ std::string status_of(const Mod& mod) { void sync(const fs::path& mods_root) { auto& s = shared(); - const auto root = official_root(mods_root); + // Official mods kept where they were before they were mods like any other go into the Mods folder. + for (const auto& line : mods::move_official_mods(mods_root)) log(logging::Level::info, "Official mods: " + line); + const auto root = list_root(mods_root); std::error_code error; fs::create_directories(root, error); @@ -184,7 +202,7 @@ void sync(const fs::path& mods_root) { authoritative = false; } } - for (auto& mod : list) mod.installed = stamp_of(root / wide(mod.full_name)); + for (auto& mod : list) mod.installed = stamp_of(mods_root / wide(mod.full_name)); { std::lock_guard lock(s.mutex); s.state.mods = list; @@ -197,7 +215,7 @@ void sync(const fs::path& mods_root) { // (install()). Only what the latest version is, for that tab. for (auto& mod : list) { if (s.cancel) break; - mod.installed = stamp_of(root / wide(mod.full_name)); + mod.installed = stamp_of(mods_root / wide(mod.full_name)); try { if (mod.version.empty() || mod.download_url.empty()) ask_thunderstore(mod); } catch (const std::exception& failure) { @@ -212,13 +230,18 @@ void sync(const fs::path& mods_root) { 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)) { + std::vector gone; + 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) || name.starts_with('.') || named.contains(name)) continue; + if (!at->is_directory(error) || name.starts_with('.') || named.contains(name) || !mods::official_mod(at->path())) continue; std::error_code ignored; fs::remove_all(at->path(), ignored); + gone.push_back(name); log(logging::Level::info, "Official mods: " + name + " is no longer official and was removed"); } + try { + forget(mods_root, gone); + } catch (...) {} } } } // namespace @@ -269,32 +292,17 @@ std::vector fallback_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 (...) {} + if (!fs::is_directory(copy, error) || !stamp_of(copy).empty()) continue; + // Installed by the player from Thunderstore: it is the official mod, where it is, rather + // than downloaded again. + const auto version = manifest_version(copy); + write_stamp(copy, version); + done.push_back(std::format("{} (v{}), installed from Thunderstore, counts as the official mod", mod.full_name, + version.empty() ? "?" : version)); } for (fs::directory_iterator at(mods_root, error), end; !error && at != end; at.increment(error)) { const auto name = utf8(at->path().filename().wstring()); @@ -362,7 +370,7 @@ void install(const fs::path& mods_root, const std::string& full_name) { try { if (mod.version.empty() || mod.download_url.empty()) ask_thunderstore(mod); const auto was = mod.installed; - download(official_root(mods_root), mod, s.cancel); + download(mods_root, mod, s.cancel); mod.installed = mod.version; log(logging::Level::info, std::format("Official mods: {} v{} {}", mod.full_name, mod.version, was.empty() ? "installed" : "updated from v" + was)); @@ -397,9 +405,9 @@ void uninstall(const fs::path& mods_root, const std::string& full_name) { } std::thread([mods_root, full_name] { auto& s = shared(); - const auto folder = official_root(mods_root) / wide(full_name); + const auto folder = mods_root / wide(full_name); std::error_code error; - // The stamp first: a folder without it is no mod to the game, whatever is left of it. + // The stamp first: a folder without it is no official mod, whatever is left of it. fs::remove(folder / std::string(mods::official_stamp), error); const bool gone = !error; if (gone) { @@ -407,7 +415,9 @@ void uninstall(const fs::path& mods_root, const std::string& full_name) { if (error) log(logging::Level::warning, std::format("Official mods: some of {} could not be deleted ({}); it is uninstalled all the same", full_name, error.message())); - (void)mods::set_official_off(mods_root, full_name, false); + try { + forget(mods_root, {full_name}); + } catch (...) {} log(logging::Level::info, "Official mods: " + full_name + " uninstalled"); } else { log(logging::Level::warning, std::format("Official mods: {} could not be uninstalled: {}", full_name, error.message())); diff --git a/Launcher/official_mods.h b/Launcher/official_mods.h index 1f71e5fc..ebd4a0c9 100644 --- a/Launcher/official_mods.h +++ b/Launcher/official_mods.h @@ -10,12 +10,12 @@ // OFFICIAL tab, where the player installs, updates and turns them off. 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. Installed, they live in Mods/.reskate-official/ -// (Engine/Vfs/builtin_packs.h), which the game merges after every installed mod. +// no network. Installed, each is a mod like any other in Mods/ and mods.json, with a +// stamp naming its version (Engine/Vfs/builtin_packs.h); a new one goes at the bottom of the load order. // // As the launcher starts it reads the list and, on a thread of its own, looks up each one's latest -// version: a copy the player installed from Thunderstore is moved over, and copies left in the -// Mods folder (and mods retired with an official one) go to the Recycle Bin and out of mods.json. +// version: a copy the player installed from Thunderstore counts as the official mod where it is, +// and mods retired with an official one go to the Recycle Bin and out of mods.json. // Nothing is downloaded until the player asks (install()). Play waits for that and for downloads // under way. namespace dingosdk::official_mods { @@ -57,8 +57,8 @@ 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; +// Each official mod in the Mods folder without its stamp (installed by the player from Thunderstore) +// is stamped with its manifest's version, as the official mod; 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/cmake/Apps.cmake b/cmake/Apps.cmake index 99671963..0e540d10 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -65,6 +65,9 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) add_executable(dingosdk_mod_merge_grid_tests Engine/Vfs/Test/mod_merge_grid_tests.cpp) target_link_libraries(dingosdk_mod_merge_grid_tests PRIVATE dingosdk_mods) add_test(NAME mod_merge_grid COMMAND dingosdk_mod_merge_grid_tests "${DINGOSDK_TEST_GAME_ROOT}") + add_executable(dingosdk_mod_conflicts_tests Engine/Vfs/Test/mod_conflicts_tests.cpp) + target_link_libraries(dingosdk_mod_conflicts_tests PRIVATE dingosdk_mods) + add_test(NAME mod_conflicts COMMAND dingosdk_mod_conflicts_tests "${DINGOSDK_TEST_GAME_ROOT}") add_executable(dingosdk_mod_build_stamp_tests Engine/Vfs/Test/mod_build_stamp_tests.cpp) target_link_libraries(dingosdk_mod_build_stamp_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_build_stamp_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/cmake/Core.cmake b/cmake/Core.cmake index 1d8df8e7..b0db0e8e 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -103,6 +103,7 @@ if(WIN32) Engine/Vfs/mod_merge_combine.cpp Engine/Vfs/mod_merge_overrides.cpp Engine/Vfs/mod_merge_conflicts.cpp + Engine/Vfs/mod_merge_scan_cache.cpp Engine/Vfs/mod_merge_textures.cpp Engine/Vfs/mod_merge_grid.cpp Engine/Vfs/mod_merge_load_screens.cpp