mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 16:17:06 +02:00
Map downloader - A host or dedicated server now tells joiners which Thunderstore package its map comes from (the Mods folder name plus manifest.json). - A player without the map gets a card: icon, name, author, version and description, with DOWNLOAD and CANCEL. It works with mouse, keyboard and controller. - Only packages listed as Maps in the ReSkate community are offered, and never deprecated, rejected or NSFW ones. - On yes the package is downloaded, installed into Mods and applied as a live mod, then the session loads the map. A small card on the right shows the progress, so the player can keep skating. - The player stays connected while it downloads: the host gives a player who says they are fetching the map up to 45 minutes instead of the usual load time. - When the server changes map with the player in it, the card says so. - Protocol version 47: map package on map_offer and world_state, and a "fetching the map" flag on map_request and world_ready. Joining - The pause-menu server browser shows a CONNECTING card over the page while a join is under way, and a COULDN'T CONNECT card with the reason when it does not get in. - The session and voice tabs no longer show before the player is in. - Leaving without the map (declined or cancelled download) no longer counts towards the failed-join limit, on servers and player hosts. Input - A card that asks something holds the game's input until the buttons are released. - Steam's overlay hooks XInput in front of ReSkate and answers the game itself, so a controller reached the game behind menus. ReSkate now hooks that hook as well. Mod merging - Mods are read on several threads, at launch and for live merges. A live merge runs below the game's priority. - The paid-cosmetics check reads the game's files and the mods on several threads. - A live merge reuses the paid-cosmetics verdicts it already has and reports its progress and timings. Launcher - NSFW mods and their categories are not shown in the mod browser.
554 lines
28 KiB
C++
554 lines
28 KiB
C++
#include "live_mods.h"
|
|
#include "mod_layers.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Game/Abi/native_data.h"
|
|
#include "Engine/Game/Build/20260929/engine.h"
|
|
#include "Engine/Game/World/custom_level_manifest.h"
|
|
#include "Engine/Core/Platform/memory.h"
|
|
#include "Engine/Core/Platform/path_text.h"
|
|
#include "Engine/Game/Build/20260929/live_mods.h"
|
|
#include "Engine/Game/Build/addresses.h"
|
|
#include "Engine/Vfs/mod_catalog.h"
|
|
#include "Engine/Vfs/mod_scoring.h"
|
|
#include "Extension/World/loading_screen.h"
|
|
#include "Extension/UI/Overlay/overlay.h"
|
|
#include <format>
|
|
#include <fstream>
|
|
#include <Windows.h>
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <atomic>
|
|
#include <array>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <thread>
|
|
|
|
namespace dingosdk::live_mods {
|
|
namespace {
|
|
namespace native = addr::live_mods;
|
|
using Register = void (*)(void *registry, const char *name);
|
|
using Find = void *(*)(void *registry, const char *name, char prefixed);
|
|
using Attach = void *(*)(void *superbundle, unsigned kind, void *chunk, int mode);
|
|
using PrepareToc = void (*)(void *data);
|
|
|
|
std::atomic<bool> merging{};
|
|
std::atomic<bool> reload_after{}, reload_ready{};
|
|
std::mutex state_mutex;
|
|
std::string last_status;
|
|
Progress merge_progress; // under state_mutex, while merging
|
|
std::optional<std::vector<std::filesystem::path>> pending_levels;
|
|
std::optional<std::vector<std::string>> applied; // set by a live apply; the launch's list before one
|
|
std::string current_level;
|
|
|
|
template <std::size_t N> bool matches(std::uintptr_t address, const std::array<std::uint8_t, N> &prefix) {
|
|
std::array<std::uint8_t, N> actual{};
|
|
return memory::read_bytes(address, actual.data(), N) && actual == prefix;
|
|
}
|
|
// A Frostbite string: up to 15 bytes inline, else a pointer; bit 0x80 of byte +0xf says which.
|
|
std::string engine_string(std::uintptr_t at) {
|
|
std::uint8_t flag{};
|
|
if (!memory::read_bytes(at + 0xf, &flag, 1)) return {};
|
|
std::uintptr_t source = at;
|
|
if ((flag & 0x80) && !memory::read_bytes(at, &source, sizeof(source))) return {};
|
|
char text[256]{};
|
|
if (!memory::read_bytes(source, text, sizeof(text) - 1)) return {};
|
|
return text;
|
|
}
|
|
void *find_chunk(std::uintptr_t registry, const std::string &name) {
|
|
std::uintptr_t manifest{}, begin{}, end{};
|
|
if (!memory::read_bytes(registry + native::registry_manifest, &manifest, 8) || !manifest ||
|
|
!memory::read_bytes(manifest + native::manifest_chunks, &begin, 8) ||
|
|
!memory::read_bytes(manifest + native::manifest_chunks + 8, &end, 8))
|
|
return nullptr;
|
|
for (auto at = begin; at < end && at < begin + 8 * 1024; at += 8) {
|
|
std::uintptr_t chunk{};
|
|
if (memory::read_bytes(at, &chunk, 8) && chunk && _stricmp(engine_string(chunk + native::chunk_name).c_str(), name.c_str()) == 0)
|
|
return reinterpret_cast<void *>(chunk);
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// Registers every superbundle the merged mods list that the game does not
|
|
// know yet, and lists it under its install chunk. Returns the names added.
|
|
std::vector<std::string> register_superbundles(const std::vector<std::pair<std::string, std::string>> &wanted,
|
|
std::string &problem) {
|
|
std::vector<std::string> added;
|
|
const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
|
|
if (!matches(base + native::register_superbundle, native::register_superbundle_prefix) ||
|
|
!matches(base + native::find_superbundle, native::find_superbundle_prefix) ||
|
|
!matches(base + native::attach_superbundle, native::attach_superbundle_prefix)) {
|
|
problem = "this game build differs, so new maps need a restart";
|
|
return added;
|
|
}
|
|
const auto objects = layout_objects();
|
|
std::uintptr_t registry{};
|
|
if (!objects.manager || !objects.layers ||
|
|
!memory::read_bytes(reinterpret_cast<std::uintptr_t>(objects.layers) + native::layers_registry, ®istry, 8) ||
|
|
!registry) {
|
|
problem = "the game's superbundle registry is not available";
|
|
return added;
|
|
}
|
|
const auto register_one = reinterpret_cast<Register>(base + native::register_superbundle);
|
|
const auto find = reinterpret_cast<Find>(base + native::find_superbundle);
|
|
const auto attach = reinterpret_cast<Attach>(base + native::attach_superbundle);
|
|
// The engine keeps the name pointer; these live for the rest of the process.
|
|
static std::deque<std::string> names;
|
|
auto *lock = reinterpret_cast<CRITICAL_SECTION *>(static_cast<char *>(objects.manager) + native::manager_lock);
|
|
EnterCriticalSection(lock);
|
|
struct Leave { CRITICAL_SECTION *lock; ~Leave() { LeaveCriticalSection(lock); } } leave{lock};
|
|
for (const auto &[name, chunk_name] : wanted) {
|
|
auto *registry_object = reinterpret_cast<void *>(registry);
|
|
if (find(registry_object, name.c_str(), 0)) continue;
|
|
auto *chunk = find_chunk(registry, chunk_name);
|
|
if (!chunk) {
|
|
problem = "install chunk " + chunk_name + " not found for " + name;
|
|
continue;
|
|
}
|
|
names.push_back(name);
|
|
register_one(registry_object, names.back().c_str());
|
|
if (auto *superbundle = find(registry_object, names.back().c_str(), 0)) {
|
|
attach(superbundle, 0, chunk, 1);
|
|
added.push_back(name);
|
|
} else {
|
|
problem = name + " could not be registered";
|
|
}
|
|
}
|
|
return added;
|
|
}
|
|
|
|
// Case-insensitive djb2, as the engine hashes bundle names.
|
|
std::uint32_t bundle_hash(std::string_view name) {
|
|
std::uint32_t hash = 5381;
|
|
for (auto c : name) {
|
|
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + 32);
|
|
hash = hash * 33U ^ static_cast<std::uint8_t>(c);
|
|
}
|
|
return hash;
|
|
}
|
|
// The root level description, from whichever asset domain holds it.
|
|
std::uintptr_t find_root_description(std::uintptr_t base) {
|
|
const auto find = game::native_data().find_asset;
|
|
if (!find) return 0;
|
|
for (std::uint16_t domain = 0; domain < 0xbbf; ++domain) {
|
|
std::uintptr_t owner{};
|
|
if (!memory::read_bytes(base + addr::engine::domain_owners + domain * 8ULL, &owner, 8) || !owner) continue;
|
|
if (const auto asset = find(domain, native::root_description)) return asset;
|
|
}
|
|
return 0;
|
|
}
|
|
// Adds every level to the root's on-demand list that it does not hold yet.
|
|
// Maps enabled at launch (or installed then) are there already, from globals.
|
|
std::vector<std::string> register_on_demand_levels(const std::vector<std::string> &levels, std::string &problem) {
|
|
std::vector<std::string> added;
|
|
if (levels.empty()) return added;
|
|
const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
|
|
const auto description = find_root_description(base);
|
|
if (!description) {
|
|
problem = "the root level description is not loaded";
|
|
return added;
|
|
}
|
|
const auto field = description + native::description_on_demand;
|
|
std::uintptr_t entries{};
|
|
std::uint32_t count{};
|
|
if (!memory::read_bytes(field, &entries, 8) || !entries || !memory::read_bytes(entries - 4, &count, 4) || count > 4096) {
|
|
problem = "the root level description's on-demand list is unreadable";
|
|
return added;
|
|
}
|
|
std::vector<std::byte> rows(std::size_t{count} * native::on_demand_entry_size);
|
|
if (!memory::read_bytes(entries, rows.data(), rows.size())) {
|
|
problem = "the root level description's on-demand list is unreadable";
|
|
return added;
|
|
}
|
|
const auto holds = [&](const std::string &level) {
|
|
for (std::uint32_t i = 0; i < count; ++i) {
|
|
std::uintptr_t name{};
|
|
std::memcpy(&name, rows.data() + i * native::on_demand_entry_size, 8);
|
|
char text[260]{};
|
|
if (name && memory::read_bytes(name, text, sizeof(text) - 1) && _stricmp(text, level.c_str()) == 0) return true;
|
|
}
|
|
return false;
|
|
};
|
|
static std::deque<std::string> names; // the engine keeps these pointers
|
|
for (const auto &level : levels) {
|
|
if (holds(level)) continue;
|
|
names.push_back(level);
|
|
std::array<std::byte, native::on_demand_entry_size> row{};
|
|
const auto pointer = reinterpret_cast<std::uintptr_t>(names.back().c_str());
|
|
const auto hash = bundle_hash(level);
|
|
std::memcpy(row.data(), &pointer, 8);
|
|
std::memcpy(row.data() + 8, &hash, 4);
|
|
rows.insert(rows.end(), row.begin(), row.end());
|
|
added.push_back(level);
|
|
}
|
|
if (added.empty()) return added;
|
|
// A new array in the engine's layout: 16 header bytes ending in the count,
|
|
// then the entries. The old array stays where it is, owned by globals.
|
|
const auto total = static_cast<std::uint32_t>(rows.size() / native::on_demand_entry_size);
|
|
auto *block = static_cast<std::byte *>(VirtualAlloc(nullptr, 16 + rows.size(), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
|
|
if (!block) {
|
|
problem = "no memory for the on-demand list";
|
|
return {};
|
|
}
|
|
std::memcpy(block + 12, &total, 4);
|
|
std::memcpy(block + 16, rows.data(), rows.size());
|
|
const auto replacement = reinterpret_cast<std::uintptr_t>(block + 16);
|
|
SIZE_T written{};
|
|
if (!WriteProcessMemory(GetCurrentProcess(), reinterpret_cast<void *>(field), &replacement, 8, &written) || written != 8) {
|
|
problem = "the on-demand list could not be replaced";
|
|
return {};
|
|
}
|
|
return added;
|
|
}
|
|
|
|
// Hands the game the TOC a live merge rewrote for a superbundle it mounted at
|
|
// launch: globals (loading-screen widgets of maps added since) and items (the
|
|
// cosmetics bundle the ownables system reloads at every level load, with its
|
|
// item collections and bundle ref table). Bundles are looked up in the TOC data
|
|
// when they load, so the top layer's entry is pointed at a copy of the new
|
|
// file; the old copy stays allocated for anything still holding it. The merge
|
|
// always writes these TOCs, so the top layer is the merged patch's.
|
|
bool refresh_toc(const std::string &superbundle, const std::filesystem::path &file, std::string &problem) {
|
|
std::ifstream in(file, std::ios::binary);
|
|
std::vector<char> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
if (bytes.size() <= native::toc_header + 4) {
|
|
problem = path_utf8(file.filename()) + " could not be read";
|
|
return false;
|
|
}
|
|
const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
|
|
if (!matches(base + native::find_superbundle, native::find_superbundle_prefix) ||
|
|
!matches(base + native::prepare_toc, native::prepare_toc_prefix)) {
|
|
problem = "this game build differs";
|
|
return false;
|
|
}
|
|
const auto objects = layout_objects();
|
|
std::uintptr_t registry{};
|
|
if (!objects.manager || !objects.layers ||
|
|
!memory::read_bytes(reinterpret_cast<std::uintptr_t>(objects.layers) + native::layers_registry, ®istry, 8) || !registry) {
|
|
problem = "the game's superbundle registry is not available";
|
|
return false;
|
|
}
|
|
auto *lock = reinterpret_cast<CRITICAL_SECTION *>(static_cast<char *>(objects.manager) + native::manager_lock);
|
|
EnterCriticalSection(lock);
|
|
struct Leave { CRITICAL_SECTION *lock; ~Leave() { LeaveCriticalSection(lock); } } leave{lock};
|
|
const auto find = reinterpret_cast<Find>(base + native::find_superbundle);
|
|
const auto sb = reinterpret_cast<std::uintptr_t>(find(reinterpret_cast<void *>(registry), superbundle.c_str(), 0));
|
|
std::uintptr_t mounts{}, mounts_end{};
|
|
if (!sb || !memory::read_bytes(sb + native::superbundle_mounts, &mounts, 8) ||
|
|
!memory::read_bytes(sb + native::superbundle_mounts + 8, &mounts_end, 8) || mounts == mounts_end) {
|
|
problem = superbundle + " is not mounted";
|
|
return false;
|
|
}
|
|
// The engine keeps TOC data in native byte order; convert a copy the same way.
|
|
reinterpret_cast<PrepareToc>(base + native::prepare_toc)(bytes.data() + native::toc_header);
|
|
std::uint32_t wanted_format{};
|
|
std::memcpy(&wanted_format, bytes.data() + native::toc_header, 4);
|
|
std::size_t swapped{};
|
|
std::string seen;
|
|
for (auto at = mounts; at < mounts_end && at < mounts + 8 * 16; at += 8) {
|
|
std::uintptr_t mount{}, entry{}, entries_end{}, toc{}, data{}, owned{};
|
|
std::uint32_t format{};
|
|
if (!memory::read_bytes(at, &mount, 8) || !mount || !memory::read_bytes(mount + native::mount_tocs, &entry, 8) ||
|
|
!memory::read_bytes(mount + native::mount_tocs + 8, &entries_end, 8) || entry >= entries_end ||
|
|
!memory::read_bytes(entry + native::toc_entry_file, &toc, 8) || !toc ||
|
|
!memory::read_bytes(entry + native::toc_entry_data, &data, 8) ||
|
|
!memory::read_bytes(toc + native::toc_file_bytes, &owned, 8) || data != owned + native::toc_header ||
|
|
!memory::read_bytes(data, &format, 4) || format != wanted_format) {
|
|
seen += std::format(" [format {:#x}]", format);
|
|
continue; // the top layer's TOC is the merged patch's, in its format
|
|
}
|
|
auto *copy = static_cast<char *>(VirtualAlloc(nullptr, bytes.size(), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
|
|
if (!copy) break;
|
|
std::memcpy(copy, bytes.data(), bytes.size());
|
|
const auto start = reinterpret_cast<std::uintptr_t>(copy);
|
|
const auto start_data = start + native::toc_header;
|
|
const std::uint64_t size = bytes.size();
|
|
const auto write = [](std::uintptr_t to, const void *value) {
|
|
SIZE_T written{};
|
|
return WriteProcessMemory(GetCurrentProcess(), reinterpret_cast<void *>(to), value, 8, &written) && written == 8;
|
|
};
|
|
if (write(toc + native::toc_file_bytes, &start) && write(toc + native::toc_file_size, &size) &&
|
|
write(toc + native::toc_file_size + 8, &size) && write(entry + native::toc_entry_data, &start_data) &&
|
|
write(entry + native::toc_entry_data + 8, &start_data))
|
|
++swapped;
|
|
}
|
|
if (!swapped) problem = "no mounted copy of " + superbundle + " is the merged patch's (" + std::format("{:#x}", wanted_format) + "):" + seen;
|
|
return swapped != 0;
|
|
}
|
|
|
|
// True when everything a mod changes only matters once one of its maps is
|
|
// loaded: its TOCs are level superbundles (the root included, which every load
|
|
// re-reads) and globals, where maps register themselves. Such a mod needs no
|
|
// reload of the level being played; a player sees it by loading the map.
|
|
bool scan_maps_only(const std::filesystem::path &directory) {
|
|
std::error_code error;
|
|
for (std::filesystem::recursive_directory_iterator at(directory, error), end; !error && at != end; at.increment(error)) {
|
|
if (!at->is_regular_file(error) || _wcsicmp(at->path().extension().c_str(), L".toc") != 0) continue;
|
|
auto relative = mods::ascii_path(std::filesystem::relative(at->path(), directory, error));
|
|
std::ranges::transform(relative, relative.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
if (relative == "layout.toc" || relative == "win32/globals.toc" || relative.starts_with("win32/levels/")) continue;
|
|
return false;
|
|
}
|
|
return !error;
|
|
}
|
|
// Cached per folder: the overlay asks every time it redraws its apply button.
|
|
bool maps_only(const std::filesystem::path &directory) {
|
|
static std::mutex cache_mutex;
|
|
static std::map<std::wstring, bool> cache;
|
|
std::lock_guard lock(cache_mutex);
|
|
const auto key = directory.wstring();
|
|
if (const auto found = cache.find(key); found != cache.end()) return found->second;
|
|
return cache[key] = scan_maps_only(directory);
|
|
}
|
|
// Whether the mods switched on or off since the last apply change anything the
|
|
// level being played shows (cosmetics, settings), rather than only maps.
|
|
bool changes_current_level(const mods::Catalog &catalog, const std::vector<std::string> &before) {
|
|
const auto named = [](const std::vector<std::string> &names, const std::string &name) {
|
|
return std::ranges::any_of(names, [&](const std::string &other) { return _stricmp(other.c_str(), name.c_str()) == 0; });
|
|
};
|
|
std::vector<std::string> now;
|
|
for (const auto &mod : catalog.mods) now.push_back(mod.name);
|
|
const auto directory = [&](const std::string &name) -> std::optional<std::filesystem::path> {
|
|
for (const auto *list : {&catalog.mods, &catalog.inactive})
|
|
for (const auto &mod : *list)
|
|
if (_stricmp(mod.name.c_str(), name.c_str()) == 0) return mod.directory;
|
|
return std::nullopt;
|
|
};
|
|
const auto changed = [&](const std::string &name) {
|
|
const auto folder = directory(name);
|
|
return !folder || !maps_only(*folder); // a folder since removed: assume the worst
|
|
};
|
|
for (const auto &name : now)
|
|
if (!named(before, name) && changed(name)) return true;
|
|
for (const auto &name : before)
|
|
if (!named(now, name) && changed(name)) return true;
|
|
return false;
|
|
}
|
|
|
|
void set_status(std::string text, logging::Level level = logging::Level::info) {
|
|
logging::log(level, logging::Channel::assets, "Live mods: {}", text);
|
|
std::lock_guard lock(state_mutex);
|
|
last_status = std::move(text);
|
|
}
|
|
|
|
void apply_now() {
|
|
const auto started = GetTickCount64();
|
|
const auto before = applied_mods();
|
|
auto catalog = mods::load_catalog(mods::engine_data_root(), {}, false);
|
|
if (!catalog.issue.empty()) {
|
|
set_status("mods.json could not be read: " + catalog.issue, logging::Level::warning);
|
|
merging = false;
|
|
return;
|
|
}
|
|
// As at launch (mods::load_catalog): a mod that cannot be merged cleanly is left out and the
|
|
// others merged again without it, so applying mods now never loads one a restart would not.
|
|
mods::MergeReport report;
|
|
std::string left_out;
|
|
for (int round = 0;; ++round) {
|
|
// The mods in the game now were checked against the store when they went in.
|
|
report = mods::merge_mods(catalog, [](const mods::MergeProgress &now) {
|
|
std::lock_guard lock(state_mutex);
|
|
merge_progress = {now.done, now.total, now.step};
|
|
}, {.live = true, .checked = before});
|
|
if (!report.issue.empty()) break;
|
|
bool removed = false;
|
|
for (std::size_t i = catalog.mods.size(); i-- > 0;) {
|
|
const auto found = report.problems.find(catalog.mods[i].name);
|
|
if (found == report.problems.end() || found->second.empty()) continue;
|
|
if (round == 3) {
|
|
report.issue = catalog.mods[i].name + " still could not be merged cleanly; restart the game to load the others";
|
|
break;
|
|
}
|
|
logging::log(logging::Level::warning, logging::Channel::assets,
|
|
"Live mods: leaving out {}: it could not be merged cleanly ({} problem(s), first: {})",
|
|
catalog.mods[i].name, found->second.size(), found->second.front());
|
|
left_out += (left_out.empty() ? "" : ", ") + catalog.mods[i].name;
|
|
auto mod = std::move(catalog.mods[i]);
|
|
catalog.mods.erase(catalog.mods.begin() + static_cast<std::ptrdiff_t>(i));
|
|
mod.problems = found->second;
|
|
catalog.excluded.push_back(std::move(mod));
|
|
removed = true;
|
|
}
|
|
if (!report.issue.empty() || !removed) break;
|
|
}
|
|
if (!report.issue.empty()) {
|
|
set_status("could not apply: " + report.issue, logging::Level::warning);
|
|
merging = false;
|
|
return;
|
|
}
|
|
if (!left_out.empty())
|
|
overlay::notify(overlay::NoticeLevel::warning, "Left out " + left_out,
|
|
"It could not be merged cleanly. The other mods were applied without it.");
|
|
// A mod switched on now can change how tricks score from the next level load on.
|
|
if (const auto scoring = mods::check_scoring(catalog); scoring.fingerprint) {
|
|
const bool already = mods::scoring_state().fingerprint != 0;
|
|
mods::publish_scoring(scoring);
|
|
std::string names;
|
|
for (const auto &mod : scoring.mods) names += (names.empty() ? "" : ", ") + mod;
|
|
logging::log(logging::Level::warning, logging::Channel::assets,
|
|
"Live mods: {} change(s) how tricks score or the skater handles (fingerprint {:016x}); in multiplayer you are kept out of "
|
|
"throwdowns and coop challenges until the game restarts without it.",
|
|
names, scoring.fingerprint);
|
|
if (!already)
|
|
overlay::notify(overlay::NoticeLevel::warning, names + " changes scoring or physics",
|
|
"In multiplayer you are kept out of throwdowns and coop challenges until you restart without it.");
|
|
}
|
|
std::string problem;
|
|
const auto added = register_superbundles(report.superbundle_chunks, problem);
|
|
for (const auto &name : added)
|
|
logging::log(logging::Level::info, logging::Channel::assets, "Live mods: registered {}", name);
|
|
// Maps the root has no on-demand entry for (installed since launch) get one.
|
|
std::vector<std::string> levels;
|
|
for (const auto &path : mods::level_manifest_paths(catalog)) {
|
|
const auto manifest = read_custom_level_manifest(path);
|
|
for (const auto &level : manifest.levels) levels.push_back(level.asset);
|
|
}
|
|
for (const auto &level : register_on_demand_levels(levels, problem))
|
|
logging::log(logging::Level::info, logging::Channel::assets, "Live mods: added {} to the root level's on-demand list", level);
|
|
for (const auto &name : report.removed_tocs)
|
|
logging::log(logging::Level::info, logging::Channel::assets, "Live mods: removed {}", name);
|
|
// Loading screens: mods installed since launch had no row in the
|
|
// configuration, nor their widget in the globals TOC the game read then.
|
|
std::vector<loading_screen::LiveScreen> screens;
|
|
for (const auto &screen : report.load_screens) {
|
|
// A mod's copy of the configuration keeps the game's own rows; only its maps' rows are its.
|
|
if (std::ranges::none_of(levels, [&](const std::string &level) { return _stricmp(level.c_str(), screen.level.c_str()) == 0; }))
|
|
continue;
|
|
if (!placed_at_launch(screen.mod)) logging::log(logging::Level::info, logging::Channel::assets,
|
|
"Live mods: {} adds a loading screen for {}", screen.mod, screen.level);
|
|
screens.push_back({screen.level, screen.bundle, screen.widget});
|
|
}
|
|
loading_screen::set_live_screens(std::move(screens));
|
|
for (const auto *name : {"globals", "items"}) {
|
|
std::string toc_problem;
|
|
const auto file = mods::engine_data_root() / L"Mods" / L".reskate" / L"Win32" / (std::string(name) + ".toc");
|
|
if (refresh_toc(std::string("Win32/") + name, file, toc_problem))
|
|
logging::log(logging::Level::info, logging::Channel::assets, "Live mods: the game now reads the merged {} TOC", name);
|
|
else if (problem.empty())
|
|
problem = std::string(name) + " was not refreshed: " + toc_problem;
|
|
}
|
|
// Only maps added or taken away: nothing in the level being played changed,
|
|
// so it is not reloaded even when asked; the maps are there to load.
|
|
const bool reload = reload_after.exchange(false);
|
|
const bool needed = reload && changes_current_level(catalog, before);
|
|
{
|
|
std::lock_guard lock(state_mutex);
|
|
// Set with the manifests, so the client tick sees both in one update.
|
|
if (needed) reload_ready = true;
|
|
pending_levels = mods::level_manifest_paths(catalog);
|
|
applied.emplace();
|
|
for (const auto &mod : catalog.mods) applied->push_back(mod.name);
|
|
}
|
|
if (!left_out.empty()) problem += (problem.empty() ? "left out " : "; left out ") + left_out + ", which could not be merged cleanly";
|
|
// Where the time went, step by step, as the launch's merge logs it.
|
|
for (const auto ¬e : report.notes)
|
|
if (note.starts_with("Merge times:")) logging::log(logging::Level::info, logging::Channel::assets, "Live mods: {}", note);
|
|
const auto elapsed = static_cast<double>(GetTickCount64() - started) / 1000.0;
|
|
set_status("applied " + std::to_string(catalog.mods.size()) + " mod(s) in " + std::to_string(elapsed).substr(0, 4) + "s; " +
|
|
(needed ? "reloading the level"
|
|
: reload ? "only maps changed, so the level was not reloaded"
|
|
: "the next level load uses them") +
|
|
(problem.empty() ? "" : " (" + problem + ")"),
|
|
problem.empty() ? logging::Level::info : logging::Level::warning);
|
|
merging = false;
|
|
}
|
|
// The worker thread: anything a mod's files make the merge throw is a status, not the end of the game.
|
|
void run() {
|
|
try {
|
|
apply_now();
|
|
} catch (const std::exception &failure) {
|
|
set_status(std::string("could not apply: ") + failure.what(), logging::Level::warning);
|
|
merging = false;
|
|
} catch (...) {
|
|
set_status("could not apply the mods", logging::Level::warning);
|
|
merging = false;
|
|
}
|
|
}
|
|
} // namespace
|
|
|
|
std::string apply(bool reload_level) {
|
|
if (!layout_objects().manager) return "No merged mod patch was mounted at launch; restart the game to load mods.";
|
|
if (merging.exchange(true)) return "Mods are already being applied.";
|
|
{
|
|
std::lock_guard lock(state_mutex);
|
|
merge_progress = {};
|
|
}
|
|
reload_after = reload_level;
|
|
std::thread(run).detach();
|
|
return reload_level ? "Applying mods; the level reloads when they are ready, unless only maps changed."
|
|
: "Applying mods; they take effect at the next level load.";
|
|
}
|
|
bool take_reload() { return reload_ready.exchange(false); }
|
|
void set_current_level(std::string level) {
|
|
std::lock_guard lock(state_mutex);
|
|
if (current_level != level) current_level = std::move(level);
|
|
}
|
|
ApplyEffect preview(const mods::ModList &list) {
|
|
std::string level;
|
|
{
|
|
std::lock_guard lock(state_mutex);
|
|
level = current_level;
|
|
}
|
|
const auto before = applied_mods();
|
|
const auto named = [](const std::vector<std::string> &names, const std::string &name) {
|
|
return std::ranges::any_of(names, [&](const std::string &other) { return _stricmp(other.c_str(), name.c_str()) == 0; });
|
|
};
|
|
std::vector<std::string> now;
|
|
for (const auto &entry : list.entries)
|
|
if (entry.enabled) now.push_back(entry.mod.name);
|
|
const auto entry_of = [&](const std::string &name) -> const mods::ModEntry * {
|
|
for (const auto &entry : list.entries)
|
|
if (_stricmp(entry.mod.name.c_str(), name.c_str()) == 0) return &entry;
|
|
return nullptr;
|
|
};
|
|
bool reload{};
|
|
for (const auto &name : before) {
|
|
if (named(now, name)) continue;
|
|
const auto *entry = entry_of(name);
|
|
// A folder deleted since: what it held is unknown, so assume the worst.
|
|
if (!entry) {
|
|
reload = true;
|
|
continue;
|
|
}
|
|
if (!level.empty() && std::ranges::any_of(entry->mod.levels, [&](const std::string &asset) {
|
|
return _stricmp(asset.c_str(), level.c_str()) == 0;
|
|
}))
|
|
return ApplyEffect::leave_level;
|
|
reload |= !maps_only(entry->mod.directory);
|
|
}
|
|
for (const auto &name : now)
|
|
if (!named(before, name))
|
|
if (const auto *entry = entry_of(name)) reload |= !maps_only(entry->mod.directory);
|
|
return reload ? ApplyEffect::reload_level : ApplyEffect::maps_only;
|
|
}
|
|
bool busy() noexcept { return merging.load(); }
|
|
Progress progress() {
|
|
std::lock_guard lock(state_mutex);
|
|
return merge_progress;
|
|
}
|
|
bool only_maps(const std::filesystem::path& directory) { return scan_maps_only(directory); }
|
|
std::string status() {
|
|
std::lock_guard lock(state_mutex);
|
|
return last_status;
|
|
}
|
|
std::vector<std::string> applied_mods() {
|
|
{
|
|
std::lock_guard lock(state_mutex);
|
|
if (applied) return *applied;
|
|
}
|
|
std::vector<std::string> names;
|
|
for (const auto &mod : mods::catalog().mods) names.push_back(mod.name);
|
|
return names;
|
|
}
|
|
bool placed_at_launch(const std::string &name) {
|
|
const auto &launch = mods::catalog();
|
|
const auto named = [&](const mods::Mod &mod) { return _stricmp(mod.name.c_str(), name.c_str()) == 0; };
|
|
return std::ranges::any_of(launch.mods, named) || std::ranges::any_of(launch.inactive, named);
|
|
}
|
|
std::optional<std::vector<std::filesystem::path>> take_level_manifests() {
|
|
std::lock_guard lock(state_mutex);
|
|
return std::exchange(pending_levels, std::nullopt);
|
|
}
|
|
} // namespace dingosdk::live_mods
|