Files
ReSkate/Launcher/mod_manager.cpp
T
AyeZeeBB 43b998f285 Built-in content: Board Break and Road Rash, replacing their Thunderstore mods, packed with zstd
Fourth Studio's Board Break pack (ten decks and every sound) and Road Rash wounds are built into
ReSkate.dll beside the FPV drone, unpacked under Mods/.reskate-builtin and always on.

- The Thunderstore copies (Fourth_Studio-Board_Break_Pack, Fourth_Studio-Road_Rash and the
  deprecated Fourth_Studio-Board_Break, or the same put in by hand) are moved to the Recycle Bin
  as the launcher starts and dropped from mods.json; the merge leaves them out should one still
  be there, and the Thunderstore browser no longer lists, updates or installs them, a dependency
  on one included.
- Board Break's deck list, sounds and deck settings look in the built-in packs too.
- Names with spaces inside them (Board Break's "Body Impact 1.wav") unpack.
- The build makes each zip in assets/builtin into one zstd stream (ReSkateBuiltinPacker,
  Engine/Vfs/builtin_pack_format.h), which the DLL carries: repeated files across a pack cost next
  to nothing. Board Break 15.0 -> 5.4 MB, the drone 6.0 -> 5.4 MB, Road Rash 36.1 -> 34.2 MB;
  ReSkate.dll 86 -> 70 MB. Plain zips still unpack.
- Tests: the mods taken for built-in ones and their retirement, and a pack unpacking to exactly
  its files, refusing one that leaves its folder or is cut short (RESKATE_BUILTIN_PACKS also
  unpacks the packs a build made).
2026-10-16 18:38:46 -05:00

441 lines
20 KiB
C++

#include "mod_manager.h"
#include "text_encoding.h"
#include "Engine/Vfs/builtin_packs.h"
#include "Engine/Vfs/mod_list.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Platform/launcher_support.h"
#include <Windows.h>
#include <shellapi.h>
#include <miniz.h>
#include <algorithm>
#include <array>
#include <cstdio>
#include <fstream>
#include <optional>
#include <string_view>
#include <vector>
namespace fs = std::filesystem;
namespace dingosdk::launcher_mods {
namespace {
// What the game loads from a mod, and the files that only describe one.
constexpr std::array<std::string_view, 2> content_markers{"layout.toc", "reskate-levels.json"};
constexpr std::array<std::string_view, 2> info_markers{"manifest.json", "reskate-mod.json"};
// Thunderstore requires these at the top of a package (lower case here); they
// follow the mod in when it is packed one folder deeper.
constexpr std::array<std::string_view, 4> package_files{"manifest.json", "icon.png", "readme.md", "changelog.md"};
constexpr char no_mod[] = " does not contain a ReSkate mod (no layout.toc, reskate-levels.json or manifest.json).";
// Staging lives inside Mods so the final move is a rename on one volume; the
// leading dot keeps the runtime from ever treating it as a mod.
constexpr wchar_t staging_folder[] = L".reskate-install";
[[noreturn]] void fail(const std::string& message) { throw std::runtime_error(message); }
std::string lower(std::string_view text) {
std::string result(text);
for (auto& ch : result) if (ch >= 'A' && ch <= 'Z') ch = static_cast<char>(ch + ('a' - 'A'));
return result;
}
using launcher_text::utf8;
fs::path from_utf8(std::string_view value) {
const auto length = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
std::wstring output(static_cast<std::size_t>(std::max(length, 0)), L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), output.data(), length);
return output;
}
// Folder names travel into engine paths (mod_list's valid_mod_name), so map
// anything else to '_' rather than refusing the mod.
std::string folder_name(std::string_view proposed) {
std::string name;
for (const auto value : proposed) {
const auto ch = static_cast<unsigned char>(value);
const bool plain = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
value == '_' || value == '-' || value == '.' || value == ' ';
name += plain ? value : '_';
}
while (!name.empty() && (name.front() == '.' || name.front() == ' ')) name.erase(name.begin());
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
if (name.size() > mods::maximum_mod_name) name.resize(mods::maximum_mod_name);
if (name.empty()) name = "Mod";
return name;
}
template<std::size_t N> bool one_of(const std::array<std::string_view, N>& names, std::string_view file) {
return std::find(names.begin(), names.end(), lower(file)) != names.end();
}
std::string last_folder(const std::string& folder) {
auto trimmed = folder;
if (!trimmed.empty() && trimmed.back() == '/') trimmed.pop_back();
const auto slash = trimmed.rfind('/');
return slash == std::string::npos ? trimmed : trimmed.substr(slash + 1);
}
struct Root {
std::string folder; // "a/b/", or "" for the top
bool content{}; // holds something the game loads
};
// The shallowest folder holding game content (layout.toc, reskate-levels.json
// or parks/<map>.park.json), else the shallowest holding only a description.
// Content wins so a package's own manifest.json at the top cannot hide a mod
// packed one folder down.
std::optional<Root> mod_root(const std::vector<std::string>& files) {
std::optional<std::string> content, info;
std::size_t content_depth = SIZE_MAX, info_depth = SIZE_MAX;
for (const auto& file : files) {
const auto slash = file.rfind('/');
auto folder = slash == std::string::npos ? std::string() : file.substr(0, slash + 1);
const auto leaf = slash == std::string::npos ? file : file.substr(slash + 1);
bool is_content = one_of(content_markers, leaf);
if (!is_content && lower(leaf).ends_with(".park.json") && lower(last_folder(folder)) == "parks") {
folder.resize(folder.size() - std::string_view("parks/").size());
is_content = true;
}
const auto depth = static_cast<std::size_t>(std::count(folder.begin(), folder.end(), '/'));
if (is_content && depth < content_depth) { content = folder; content_depth = depth; }
if (!is_content && one_of(info_markers, leaf) && depth < info_depth) { info = folder; info_depth = depth; }
}
if (content) return Root{*content, true};
if (info) return Root{*info, false};
return std::nullopt;
}
// A file at the top of the source that goes into a mod found deeper:
// Thunderstore's package files, when the top has a manifest.json (else they
// are not a package's).
bool carried(const Root& root, const std::vector<std::string>& files, const std::string& file) {
if (root.folder.empty() || file.find('/') != std::string::npos || !one_of(package_files, file)) return false;
return std::any_of(files.begin(), files.end(), [](const std::string& top) { return lower(top) == "manifest.json"; }) &&
std::any_of(files.begin(), files.end(), [&](const std::string& top) { return lower(top) == lower(file); });
}
// Where a file of the source goes inside the mod, if anywhere. Carried package
// files replace the mod's own copies of them.
std::optional<std::string> relative_in_mod(const Root& root, const std::vector<std::string>& files, const std::string& file) {
if (carried(root, files, file)) return file;
if (!file.starts_with(root.folder)) return std::nullopt;
auto relative = file.substr(root.folder.size());
if (relative.empty() || carried(root, files, relative)) return std::nullopt;
return relative;
}
// "Team-Map-1.2.3" (a zip downloaded from Thunderstore) installs as Team-Map,
// the folder the launcher's own Thunderstore installs use.
std::string without_version(std::string name) {
const auto dash = name.rfind('-');
if (dash == std::string::npos || dash == 0) return name;
const std::string_view version(name.data() + dash + 1, name.size() - dash - 1);
if (std::count(version.begin(), version.end(), '.') != 2 || version.empty() || version.front() == '.' ||
version.back() == '.' || version.find("..") != std::string_view::npos ||
version.find_first_not_of("0123456789.") != std::string_view::npos) return name;
name.resize(dash);
return name;
}
std::string mod_name(const Root& root, const std::string& source_name, const InstallOptions& options) {
if (!options.folder.empty()) return folder_name(options.folder);
return folder_name(root.folder.empty() ? without_version(source_name) : last_folder(root.folder));
}
// Last steps in staging: a source that gives the game nothing to load is
// refused when asked, and the author goes into manifest.json.
void finish(const fs::path& staging, const Root& root, const std::string& source_name, const InstallOptions& options) {
if (options.require_content && !root.content)
fail(source_name + " has nothing for ReSkate to load: no layout.toc, reskate-levels.json or parks folder. "
"Only mods built with ReSkate Studio, and park mods, can be installed.");
const auto manifest = staging / mods::manifest_file;
std::error_code error;
if (options.author.empty() || !fs::is_regular_file(manifest, error)) return;
try {
std::string text;
{
std::ifstream input(manifest, std::ios::binary);
text.assign(std::istreambuf_iterator<char>(input), {});
}
if (text.starts_with("\xef\xbb\xbf")) text.erase(0, 3);
auto json = Json::parse(text, JsonLimits{1024 * 1024, 16, 65536});
if (!json.is_object()) return;
if (json.contains("author") && json.at("author").is_string() && !json.at("author").string().empty()) return;
json.items()["author"] = Json(options.author);
std::ofstream(manifest, std::ios::binary | std::ios::trunc) << json.dump(4) << '\n';
} catch (...) {
// An unreadable manifest is the mod's own business; the game reports it.
}
}
// Entry names from an archive are untrusted: no absolute paths, drive
// letters, alternate streams or parent references may leave the target.
std::optional<std::string> safe_entry(std::string name) {
std::replace(name.begin(), name.end(), '\\', '/');
if (name.empty() || name.front() == '/' || name.find(':') != std::string::npos) return std::nullopt;
std::string_view rest(name);
while (!rest.empty()) {
const auto slash = rest.find('/');
const auto part = rest.substr(0, slash);
if (part == "..") return std::nullopt;
if (slash == std::string_view::npos) break;
rest.remove_prefix(slash + 1);
}
if (name.starts_with("__MACOSX/")) return std::string();
return name;
}
// Package sizes come from the zip itself, so refuse one that would fill the drive.
void check_free_space(const fs::path& mods, std::uint64_t needed) {
ULARGE_INTEGER available{};
if (!GetDiskFreeSpaceExW(mods.c_str(), &available, nullptr, nullptr)) return;
constexpr std::uint64_t reserve = 1ull << 30;
if (needed > available.QuadPart || available.QuadPart - needed < reserve)
fail("This mod needs " + std::to_string(needed >> 20) + " MB, more than the drive has free.");
}
void recycle(const fs::path& path) {
std::wstring from = path.wstring();
from.push_back(L'\0'); // SHFileOperation takes a double-terminated list.
SHFILEOPSTRUCTW operation{};
operation.wFunc = FO_DELETE;
operation.pFrom = from.c_str();
operation.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_WANTNUKEWARNING | FOF_SILENT | FOF_NOERRORUI;
if (SHFileOperationW(&operation) != 0 || operation.fAnyOperationsAborted)
fail("Could not move " + utf8(path.filename().wstring()) + " to the Recycle Bin. Close anything using its files and try again.");
}
class Staging {
public:
Staging(const fs::path& mods, const std::string& name) : path_(mods / staging_folder / from_utf8(name)) {
std::error_code error;
fs::remove_all(path_, error);
fs::create_directories(path_);
}
~Staging() {
std::error_code error;
fs::remove_all(path_.parent_path(), error);
}
const fs::path& path() const { return path_; }
private:
fs::path path_;
};
// Moves the finished staging folder into place, replacing only when asked.
void commit(const fs::path& mods, const Staging& staging, const std::string& name, bool replace) {
const auto target = mods / from_utf8(name);
std::error_code error;
if (fs::exists(target, error)) {
if (!replace) throw AlreadyInstalled(name);
recycle(target);
}
if (!MoveFileExW(staging.path().c_str(), target.c_str(), MOVEFILE_WRITE_THROUGH))
fail("Could not move the mod into the Mods folder (Windows error " + std::to_string(GetLastError()) + ").");
}
// miniz is built without stdio, so the archive is read through a handle.
struct Zip {
mz_zip_archive archive{};
HANDLE file{INVALID_HANDLE_VALUE};
~Zip() {
mz_zip_reader_end(&archive);
if (file != INVALID_HANDLE_VALUE) CloseHandle(file);
}
};
std::size_t read_at(void* opaque, mz_uint64 offset, void* buffer, std::size_t size) {
OVERLAPPED position{};
position.Offset = static_cast<DWORD>(offset);
position.OffsetHigh = static_cast<DWORD>(offset >> 32);
DWORD read{};
if (size > MAXDWORD || !ReadFile(static_cast<HANDLE>(opaque), buffer, static_cast<DWORD>(size), &read, &position)) return 0;
return read;
}
struct Sink {
FILE* file{};
std::uint64_t* written{};
std::uint64_t total{};
const Progress* progress{};
const std::atomic<bool>* cancel{};
};
std::size_t write_chunk(void* opaque, mz_uint64, const void* data, std::size_t size) {
auto& sink = *static_cast<Sink*>(opaque);
if (sink.cancel->load()) return 0;
if (std::fwrite(data, 1, size, sink.file) != size) return 0;
*sink.written += size;
if (*sink.progress && sink.total) (*sink.progress)(static_cast<float>(*sink.written) / static_cast<float>(sink.total));
return size;
}
std::string install_zip(const fs::path& mods, const fs::path& source, bool replace,
const Progress& progress, const std::atomic<bool>& cancel, const InstallOptions& options) {
Zip zip;
zip.file = CreateFileW(source.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
FILE_FLAG_SEQUENTIAL_SCAN, nullptr);
LARGE_INTEGER size{};
if (zip.file == INVALID_HANDLE_VALUE || !GetFileSizeEx(zip.file, &size))
fail("Could not open " + utf8(source.filename().wstring()) + ".");
zip.archive.m_pRead = read_at;
zip.archive.m_pIO_opaque = zip.file;
if (size.QuadPart <= 0 || !mz_zip_reader_init(&zip.archive, static_cast<mz_uint64>(size.QuadPart), 0))
fail(utf8(source.filename().wstring()) + " is not a readable .zip file.");
struct Entry { mz_uint index; std::string name; bool directory; std::uint64_t size; };
std::vector<Entry> entries;
std::vector<std::string> files;
const auto count = mz_zip_reader_get_num_files(&zip.archive);
for (mz_uint index = 0; index < count; ++index) {
if (cancel) fail("Install cancelled.");
mz_zip_archive_file_stat stat{};
if (!mz_zip_reader_file_stat(&zip.archive, index, &stat)) fail("The .zip file is damaged.");
const auto name = safe_entry(stat.m_filename);
if (!name) fail("The .zip file contains an unsafe path (" + std::string(stat.m_filename) + "); it was not installed.");
if (name->empty()) continue;
const bool directory = mz_zip_reader_is_file_a_directory(&zip.archive, index);
entries.push_back({index, *name, directory, stat.m_uncomp_size});
if (!directory) files.push_back(*name);
}
const auto root = mod_root(files);
if (!root) fail(utf8(source.filename().wstring()) + no_mod);
const auto name = mod_name(*root, utf8(source.stem().wstring()), options);
std::error_code error;
if (!replace && fs::exists(mods / from_utf8(name), error)) throw AlreadyInstalled(name);
std::uint64_t total = 0, written = 0;
for (const auto& entry : entries)
if (!entry.directory && relative_in_mod(*root, files, entry.name)) total += entry.size;
check_free_space(mods, total);
Staging staging(mods, name);
for (const auto& entry : entries) {
if (cancel) fail("Install cancelled.");
const auto mapped = relative_in_mod(*root, files, entry.name);
if (!mapped) continue;
const auto& relative = *mapped;
const auto target = staging.path() / from_utf8(relative);
if (entry.directory) { fs::create_directories(target); continue; }
fs::create_directories(target.parent_path());
FILE* output{};
if (_wfopen_s(&output, target.c_str(), L"wb") || !output) fail("Could not write " + relative + ".");
Sink sink{output, &written, total, &progress, &cancel};
const bool ok = mz_zip_reader_extract_to_callback(&zip.archive, entry.index, write_chunk, &sink, 0);
std::fclose(output);
if (cancel) fail("Install cancelled.");
if (!ok) fail("Could not extract " + relative + " from the .zip file.");
}
finish(staging.path(), *root, utf8(source.filename().wstring()), options);
// Cancellation is safe before replacing the installed folder, not midway through commit.
if (progress) progress(1.f);
if (cancel) fail("Install cancelled.");
commit(mods, staging, name, replace);
return name;
}
std::string install_folder(const fs::path& mods, const fs::path& source, bool replace,
const Progress& progress, const std::atomic<bool>& cancel, const InstallOptions& options) {
std::vector<std::string> files;
std::vector<fs::path> paths;
for (auto it = fs::recursive_directory_iterator(source); it != fs::recursive_directory_iterator(); ++it) {
if (cancel) fail("Install cancelled.");
if (!it->is_regular_file()) continue;
paths.push_back(it->path());
auto relative = utf8(fs::relative(it->path(), source).generic_wstring());
files.push_back(std::move(relative));
}
const auto root = mod_root(files);
if (!root) fail(utf8(source.filename().wstring()) + no_mod);
const auto name = mod_name(*root, utf8(source.filename().wstring()), options);
std::error_code error;
if (!replace && fs::exists(mods / from_utf8(name), error)) throw AlreadyInstalled(name);
std::uint64_t total = 0, written = 0;
for (std::size_t i = 0; i < files.size(); ++i)
if (relative_in_mod(*root, files, files[i])) total += fs::file_size(paths[i]);
Staging staging(mods, name);
for (std::size_t i = 0; i < files.size(); ++i) {
if (cancel) fail("Install cancelled.");
const auto relative = relative_in_mod(*root, files, files[i]);
if (!relative) continue;
const auto target = staging.path() / from_utf8(*relative);
fs::create_directories(target.parent_path());
fs::copy_file(paths[i], target, fs::copy_options::overwrite_existing);
written += fs::file_size(paths[i]);
if (progress && total) progress(static_cast<float>(written) / static_cast<float>(total));
}
finish(staging.path(), *root, utf8(source.filename().wstring()), options);
if (progress) progress(1.f);
if (cancel) fail("Install cancelled.");
commit(mods, staging, name, replace);
return name;
}
} // namespace
fs::path mods_root(const fs::path& game_directory) {
const auto data = launcher::mod_data_arguments(game_directory, {}).empty()
? game_directory : game_directory / L"ModData" / L"Default";
return data / mods::mods_folder;
}
std::string install(const fs::path& mods, const fs::path& source, bool replace,
const Progress& progress, const std::atomic<bool>& cancel, const InstallOptions& options) {
try {
fs::create_directories(mods);
std::error_code error;
if (fs::is_directory(source, error)) {
std::error_code same;
if (fs::equivalent(source.parent_path(), mods, same))
fail(utf8(source.filename().wstring()) + " is already in the Mods folder.");
return install_folder(mods, source, replace, progress, cancel, options);
}
if (lower(utf8(source.extension().wstring())) != ".zip")
fail("Choose a .zip file or a mod folder to install.");
return install_zip(mods, source, replace, progress, cancel, options);
} catch (const AlreadyInstalled&) {
throw;
} catch (const fs::filesystem_error& failure) {
fail(std::string("Install failed: ") + failure.what());
}
}
std::vector<std::string> retire_superseded(const fs::path& mods) noexcept {
std::vector<std::string> retired;
try {
std::vector<std::pair<std::string, fs::path>> found;
std::error_code error;
for (fs::directory_iterator at(mods, error), end; !error && at != end; at.increment(error)) {
if (!at->is_directory(error)) continue;
const auto name = utf8(at->path().filename().wstring());
if (mods::superseded_by_builtin(name)) found.emplace_back(name, at->path());
}
for (const auto& [name, path] : found) {
try {
recycle(path);
retired.push_back(name);
} catch (...) {}
}
// mods.json keeps the rest as it was; a malformed one is left for the player to see.
if (!retired.empty()) {
const auto list = mods::scan_mods(mods.parent_path());
if (list.issue.empty()) mods::save_mod_order(mods, list.entries);
}
} catch (...) {}
return retired;
}
void remove(const fs::path& mods, const std::string& name) {
if (!mods::valid_mod_name(name)) fail("\"" + name + "\" is not a mod folder.");
const auto target = mods / from_utf8(name);
std::error_code error;
if (!fs::is_directory(target, error)) fail("\"" + name + "\" is not installed.");
recycle(target);
}
} // namespace dingosdk::launcher_mods