mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-24 00:35:59 +02:00
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/<Namespace-Name> 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.
This commit is contained in:
1 parent
8017c2feb3
commit
fffd34ca80
30 files changed
+2019
-616
No files matched your search
@@ -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 <Windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
namespace mods = dingosdk::mods;
|
||||
|
||||
namespace {
|
||||
void say(const char* what, const std::vector<std::string>& order, const std::vector<mods::MergeReport::Conflict>& conflicts,
|
||||
std::chrono::steady_clock::duration took) {
|
||||
std::cout << what << ": " << conflicts.size() << " conflicting pair(s) in "
|
||||
<< std::chrono::duration_cast<std::chrono::milliseconds>(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<const mods::Mod*> 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<const mods::Mod*>& order, const char* what) {
|
||||
std::vector<std::string> 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;
|
||||
}
|
||||
@@ -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 <Windows.h>
|
||||
#include <miniz.h>
|
||||
@@ -236,68 +237,71 @@ std::vector<std::string> official_off_list(const fs::path& mods_root) {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool official_off(const fs::path& mods_root, std::string_view folder) noexcept {
|
||||
std::vector<std::string> move_official_mods(const fs::path& mods_root) noexcept {
|
||||
std::vector<std::string> 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<BuiltinPack> official_packs(const fs::path& mods_root, bool with_off) noexcept {
|
||||
std::vector<BuiltinPack> packs;
|
||||
try {
|
||||
const auto off = with_off ? std::vector<std::string>{} : 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<char>(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<BuiltinPack> unpack_builtin_packs(const fs::path& mods_root, std::vector<std::string>& notes) noexcept {
|
||||
|
||||
+19
-16
@@ -25,33 +25,36 @@ std::vector<BuiltinPack> unpack_builtin_packs(const std::filesystem::path& mods_
|
||||
bool unpack_builtin_pack(std::span<const unsigned char> 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/<Namespace-Name>,
|
||||
// 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/<Namespace-Name> 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<BuiltinPack> 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<std::string> 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/<folder>) 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/<folder>) 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
|
||||
@@ -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<Open>(); }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+32
-25
@@ -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<fs::path> 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<std::string> 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 <folder>#<deck>, 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<std::string> problems;
|
||||
std::string winner; // left out for a conflict with this mod
|
||||
};
|
||||
std::map<std::size_t, Out> out;
|
||||
std::set<std::string> leaving;
|
||||
std::set<std::string, std::less<>> broken;
|
||||
std::vector<std::string> 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)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include "mod_catalog.h"
|
||||
#include "mod_merge.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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<MergeReport::Conflict> check(const std::vector<const Mod*>& 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> 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<const MergeReport::Conflict*> 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<std::string, ConflictLoss, std::less<>> conflict_losses(const std::vector<std::string>& order,
|
||||
const std::vector<MergeReport::Conflict>& conflicts, const std::set<std::string, std::less<>>& leaving = {},
|
||||
const std::function<bool(const std::string&)>& 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
|
||||
@@ -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<std::string> keys; // the items it adds or changes, lower case
|
||||
};
|
||||
|
||||
|
||||
@@ -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<std::pair<std::string, bool>>{};
|
||||
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<ModEntry> 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<ModEntry>& entries) {
|
||||
auto rows = Json::array();
|
||||
for (const auto& entry : entries) {
|
||||
|
||||
@@ -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<ModEntry>& 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
|
||||
@@ -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<std::chrono::milliseconds>(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<std::string, std::string, std::less<>> 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<CasStore> 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.
|
||||
|
||||
@@ -36,6 +36,7 @@ struct MergeReport {
|
||||
std::string lost;
|
||||
std::string asset;
|
||||
std::size_t assets{};
|
||||
std::vector<std::string> areas; // what those assets are, in a player's words (asset_area)
|
||||
};
|
||||
std::vector<Conflict> 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<std::string> 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{};
|
||||
};
|
||||
|
||||
@@ -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<std::string, fb::Sha1> 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<std::string> mods;
|
||||
};
|
||||
std::unordered_map<std::string, OwnChange> 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<std::string, std::vector<std::pair<fb::Sha1, std::string>>> 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<std::pair<std::string, fb::Sha1>> displaced;
|
||||
@@ -234,12 +231,10 @@ std::span<const unsigned char> unsigned_bytes(std::span<const std::byte> 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<Source>& 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<std::uint32_t>(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<std::uint32_t>(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<std::string, std::pair<std::size_t, std::string>> 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<std::vector<std::byte>> 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);
|
||||
|
||||
+319
-179
@@ -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 <Windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <system_error>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
|
||||
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<std::uint64_t>(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<MergeReport::Conflict>;
|
||||
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<std::pair<std::size_t, const fb::TocBundle*>> copies; // (mod, its copy), lowest priority first
|
||||
std::optional<Listing> gameListing;
|
||||
// The game's version of each asset: its sha1, and its place among the EBX.
|
||||
std::unordered_map<std::uint64_t, std::pair<fb::Sha1, std::size_t>> original;
|
||||
std::vector<std::optional<Listing>> listings; // by copy
|
||||
std::vector<std::vector<Change>> 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<void(std::size_t)>& work) {
|
||||
std::atomic<std::size_t> 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<const Mod*>& 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<fb::BundleAsset>& 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<vfs::Layout> layout;
|
||||
std::optional<CasStore> store;
|
||||
|
||||
struct Toc {
|
||||
bool read{};
|
||||
std::optional<fb::TocDocument> document;
|
||||
std::map<std::string, const fb::TocBundle*, std::less<>> 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> listing;
|
||||
std::unordered_map<std::uint64_t, std::pair<fb::Sha1, std::size_t>> 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> listing;
|
||||
std::vector<Change> changes;
|
||||
};
|
||||
struct ModState {
|
||||
std::string fingerprint;
|
||||
RelativeFiles files;
|
||||
std::map<std::string, Toc, std::less<>> tocs; // by lower-case superbundle
|
||||
std::map<std::string, Copy, std::less<>> copies; // by "superbundle|bundle"
|
||||
};
|
||||
std::map<std::string, Toc, std::less<>> gameTocs;
|
||||
std::map<std::string, GameBundle, std::less<>> gameBundles; // by "superbundle|bundle"
|
||||
std::map<std::wstring, ModState, std::less<>> 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<std::string, bool> 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<std::pair<std::size_t, const ConflictChecker::State::Copy*>> 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<const Mod*>& mods, const CasStore& store,
|
||||
const fs::path& baseRoot, const fs::path& gameRoot) {
|
||||
Rows decide(const Shared& shared, const std::vector<const Mod*>& 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<std::uint64_t, std::vector<const Change*>> byAsset;
|
||||
std::unordered_map<std::uint64_t, std::vector<Seen>> byAsset;
|
||||
std::vector<std::uint64_t> 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<const std::string*> 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<const Seen*> 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<fb::Sha1, fb::ebx::Document> documents;
|
||||
std::vector<const fb::ebx::Document*> 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<const char*>(seen.change->asset->sha1.bytes.data()), seen.change->asset->sha1.bytes.size());
|
||||
std::optional<bool> 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<fb::Sha1, fb::ebx::Document> documents;
|
||||
std::vector<const fb::ebx::Document*> 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<MergeReport::Conflict> find_conflicts(const std::vector<const Mod*>& mods,
|
||||
const std::map<const Mod*, RelativeFiles>& 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<std::string, std::vector<std::size_t>> shippedBy; // lower case relative path, mods highest priority first
|
||||
std::map<std::string, std::string> 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>()) {
|
||||
state_->gameRoot = data_root;
|
||||
state_->baseRoot = data_root / L"Data";
|
||||
state_->output = data_root / mods_folder / generated_folder;
|
||||
}
|
||||
|
||||
ConflictChecker::~ConflictChecker() = default;
|
||||
|
||||
std::vector<MergeReport::Conflict> ConflictChecker::check(const std::vector<const Mod*>& 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<State::ModState*> 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<std::string, std::vector<std::size_t>> shippedBy; // lower case, mods highest priority first
|
||||
std::map<std::string, std::string> 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<fb::TocDocument> tocs;
|
||||
tocs.reserve([&] {
|
||||
std::size_t count{};
|
||||
for (const auto& [relative, by] : shippedBy) count += by.size() + 1;
|
||||
return count;
|
||||
}());
|
||||
std::vector<Shared> bundles;
|
||||
for (const auto& [relative, by] : shippedBy) {
|
||||
std::map<std::string, Shared> 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<Shared> bundles;
|
||||
std::vector<std::pair<State::GameBundle*, const fb::TocBundle*>> gameUnread;
|
||||
struct CopyUnread {
|
||||
State::Copy* copy;
|
||||
const fs::path* folder;
|
||||
const fb::TocBundle* bundle;
|
||||
const State::GameBundle* game;
|
||||
};
|
||||
std::vector<CopyUnread> copyUnread;
|
||||
for (const auto& [relative, by] : shippedBy) {
|
||||
std::map<std::string, std::vector<std::pair<std::size_t, const fb::TocBundle*>>> 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<fb::BundleAsset>& 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<Rows> 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<std::pair<std::size_t, std::size_t>> 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<Rows> 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<std::string, ConflictLoss, std::less<>> conflict_losses(const std::vector<std::string>& order,
|
||||
const std::vector<MergeReport::Conflict>& conflicts, const std::set<std::string, std::less<>>& leaving,
|
||||
const std::function<bool(const std::string&)>& exempt) {
|
||||
std::map<std::string, ConflictLoss, std::less<>> 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<std::string_view, std::string_view> 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
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <span>
|
||||
@@ -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<fb::BundleFileInfo>& files, std::size_t first,
|
||||
const std::set<fb::Guid>& tocChunks, const fs::path& gameRoot);
|
||||
const std::set<fb::Guid>& tocChunks, const fs::path& gameRoot,
|
||||
const std::vector<std::uint32_t>* 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<std::uint32_t> repair; // EBX with type references to repair
|
||||
std::vector<std::uint32_t> textures; // textures whose header is changed so they stream
|
||||
std::vector<Mesh> 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<std::string, std::string, std::less<>> fingerprints) noexcept;
|
||||
// By mod folder name and "superbundle|bundle" (lower case).
|
||||
[[nodiscard]] std::optional<Bundle> 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<std::string, std::string, std::less<>> fingerprints_;
|
||||
mutable std::mutex mutex_;
|
||||
std::map<std::string, std::map<std::string, Bundle, std::less<>>, 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<const Mod*>& 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<MergeReport::Conflict> find_conflicts(const std::vector<const Mod*>& mods,
|
||||
const std::map<const Mod*, RelativeFiles>& 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<Source>& 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
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "mod_merge_internal.h"
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
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<std::uint32_t> numbers(const std::string& text) {
|
||||
std::vector<std::uint32_t> 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<std::string, std::string, std::less<>> 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<std::string, Bundle, std::less<>>* 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::Bundle> 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<std::uint32_t>& 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
|
||||
@@ -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<const Mod*>& mods,
|
||||
const std::map<const Mod*, RelativeFiles>& modFiles, bool storeKnown) {
|
||||
std::string inputs = stamp_header;
|
||||
|
||||
@@ -41,7 +41,8 @@ std::optional<std::uint32_t> block_at(std::span<const std::byte> encoded, std::u
|
||||
} // namespace
|
||||
|
||||
StreamedTextures stream_resident_textures(const CasStore& store, const fs::path& root, fb::BinaryBundle& manifest, std::vector<fb::BundleFileInfo>& files, std::size_t first,
|
||||
const std::set<fb::Guid>& tocChunks, const fs::path& gameRoot) {
|
||||
const std::set<fb::Guid>& tocChunks, const fs::path& gameRoot,
|
||||
const std::vector<std::uint32_t>* only) {
|
||||
StreamedTextures result;
|
||||
if (manifest.chunks.empty() || manifest.chunkMetadata.empty()) return result;
|
||||
std::optional<native_db::Node> 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<std::uint32_t>(resourcesAt + index)) == only->end()) continue;
|
||||
const auto& file = files[resourcesAt + index];
|
||||
if (!file.location.patch) continue; // the game's own texture
|
||||
try {
|
||||
|
||||
@@ -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<const mods::Mod*> 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)
|
||||
|
||||
@@ -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<mods::ModList>; // process lifetime, as the threads that fill it
|
||||
static auto* officials = new FolderRead<std::vector<mods::BuiltinPack>>;
|
||||
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<mods::BuiltinPack>{};
|
||||
// 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<std::string> 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::size_t>(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);
|
||||
|
||||
@@ -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<fs::path> 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;
|
||||
|
||||
+125
-6
@@ -5,6 +5,9 @@
|
||||
|
||||
#include "problem.h"
|
||||
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
@@ -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<Announcement::Message> 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<std::uint64_t>();
|
||||
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<std::string> 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();
|
||||
|
||||
+70
-1
@@ -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<const char*, 7> 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<bool> loading{};
|
||||
std::mutex mutex;
|
||||
bool arrived{}; // worker -> UI, under the mutex
|
||||
std::optional<Message> incoming;
|
||||
std::optional<Message> 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<std::string, std::less<>> 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<std::string, std::less<>> 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<std::pair<std::string, std::string>> conflict_choices;
|
||||
bool resolving{}; // the Resolve conflicts popup is to open
|
||||
std::vector<std::string> 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<std::string, float, std::less<>> 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<mods::ConflictChecker> checker; // the worker's alone while it runs
|
||||
std::thread worker;
|
||||
std::atomic<bool> 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<mods::MergeReport::Conflict> rows;
|
||||
std::vector<std::string> order; // the mods checked, highest priority first
|
||||
std::map<std::string, mods::ConflictLoss, std::less<>> losses; // the mods that would be left out
|
||||
std::map<std::string, std::vector<std::string>, std::less<>> beats; // and whom each winner leaves out
|
||||
struct Result {
|
||||
bool ready{};
|
||||
std::uint64_t asked{};
|
||||
std::vector<mods::MergeReport::Conflict> rows;
|
||||
std::vector<std::string> order;
|
||||
} pending; // under mutex
|
||||
} conflicts;
|
||||
std::string message;
|
||||
bool message_error{};
|
||||
std::vector<std::string> 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`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+779
-145
File diff suppressed because it is too large.
Load diff
@@ -355,6 +355,7 @@ void start_store_install(ModsPanel& panel, std::vector<thunderstore::Package> 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");
|
||||
|
||||
+48
-38
@@ -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<std::string>& 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/<Namespace-Name>), 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<bool>& 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<bool>& 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<std::string> named;
|
||||
for (const auto& mod : list) named.insert(mod.full_name);
|
||||
for (fs::directory_iterator at(root, error), end; !error && at != end; at.increment(error)) {
|
||||
std::vector<std::string> 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<Mod> fallback_list() {
|
||||
std::vector<std::string> adopt_and_retire(const fs::path& mods_root, const std::vector<Mod>& official) {
|
||||
std::vector<std::string> done;
|
||||
try {
|
||||
const auto root = official_root(mods_root);
|
||||
std::error_code error;
|
||||
fs::create_directories(root, error);
|
||||
std::vector<std::string> gone;
|
||||
for (const auto& mod : official) {
|
||||
const auto copy = mods_root / wide(mod.full_name);
|
||||
if (!fs::is_directory(copy, error)) continue;
|
||||
const auto target = root / wide(mod.full_name);
|
||||
if (stamp_of(target).empty()) {
|
||||
// Installed by the player already: moved over rather than downloaded again.
|
||||
const auto version = manifest_version(copy);
|
||||
fs::remove_all(target, error);
|
||||
fs::rename(copy, target, error);
|
||||
if (!error) {
|
||||
write_stamp(target, version);
|
||||
gone.push_back(mod.full_name);
|
||||
done.push_back(std::format("{} (v{}) moved from the Mods folder", mod.full_name, version.empty() ? "?" : version));
|
||||
continue;
|
||||
}
|
||||
error.clear();
|
||||
}
|
||||
try {
|
||||
launcher_mods::remove(mods_root, mod.full_name);
|
||||
gone.push_back(mod.full_name);
|
||||
done.push_back(mod.full_name + ": the copy in the Mods folder went to the Recycle Bin");
|
||||
} catch (...) {}
|
||||
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()));
|
||||
|
||||
@@ -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/<Namespace-Name>
|
||||
// (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/<Namespace-Name> 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<Mod> parse_list(std::string_view json);
|
||||
// The list ReSkate falls back on when it has never read one: Fourth Studio's Board Break pack and
|
||||
// Road Rash.
|
||||
std::vector<Mod> fallback_list();
|
||||
// For each official mod not in place yet, a copy in the Mods folder is moved in (stamped with its
|
||||
// manifest's version); the copies left in the Mods folder and retired mods go to the Recycle Bin;
|
||||
// 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<std::string> adopt_and_retire(const std::filesystem::path& mods_root, const std::vector<Mod>& official);
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user