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:
AyeZeeBB committed 2026-10-18 16:33:53 -05:00
1 parent 8017c2feb3
commit fffd34ca80
30 files changed
+2019 -616

No files matched your search

+77
View File
@@ -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;
}
+54 -56
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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>(); }
+3
View File
@@ -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
View File
@@ -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)
+70
View File
@@ -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
+1 -1
View File
@@ -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
};
+34
View File
@@ -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) {
+8
View File
@@ -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
+16 -4
View File
@@ -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.
+2 -1
View File
@@ -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{};
};
+68 -45
View File
@@ -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
View File
@@ -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({&copy, &mods[mod]->directory, bundle, &game});
shared.copies.emplace_back(mod, &copy);
}
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
+45 -13
View File
@@ -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
+113
View File
@@ -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
+9
View File
@@ -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;
+3 -1
View File
@@ -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 {
+4 -2
View File
@@ -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)
+24 -35
View File
@@ -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);
+64 -33
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+8 -7
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -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
View File
@@ -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()));
+6 -6
View File
@@ -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);
}
+3
View File
@@ -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}")
+1
View File
@@ -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