mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 23:26:06 +02:00
Now that features are Luau plugins, nothing needs mods of ReSkate's own: - The OFFICIAL tab and its rail tile are gone, with Launcher/official_mods (the backend's list, the install, update and sync, and Play's wait for them). - GET MODS lists and updates Board Break, Road Rash and the rest like any other package, and MY MODS no longer marks them Official or keeps them out of UPDATE ALL. - The merge may leave one out over a conflict like any other mod; the version stamp, the on/off helpers and the move out of the old .reskate-official folder are gone from builtin_packs. - Installed copies stay in Mods as ordinary mods. The retired first Board Break is still left out.
478 lines
28 KiB
C++
478 lines
28 KiB
C++
// Launcher mod manager: install from .zip and folders, the Mods list and mods.json.
|
|
#include "Launcher/mod_manager.h"
|
|
|
|
#include "Engine/Vfs/builtin_pack_format.h"
|
|
#include "Engine/Vfs/builtin_packs.h"
|
|
#include <zstd.h>
|
|
#include "Engine/Vfs/mod_list.h"
|
|
|
|
#include <Windows.h>
|
|
|
|
#include <miniz.h>
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <map>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace fs = std::filesystem;
|
|
using namespace dingosdk;
|
|
|
|
namespace {
|
|
|
|
int failures = 0;
|
|
|
|
void check(bool condition, const char* what) {
|
|
if (condition) return;
|
|
std::cerr << "FAIL: " << what << '\n';
|
|
++failures;
|
|
}
|
|
|
|
void write(const fs::path& path, std::string_view text) {
|
|
fs::create_directories(path.parent_path());
|
|
std::ofstream(path, std::ios::binary) << text;
|
|
}
|
|
|
|
std::string read(const fs::path& path) {
|
|
std::ifstream input(path, std::ios::binary);
|
|
return {std::istreambuf_iterator<char>(input), {}};
|
|
}
|
|
|
|
// A stored (uncompressed) .zip, enough to exercise the reader.
|
|
void make_zip(const fs::path& path, const std::vector<std::pair<std::string, std::string>>& files) {
|
|
std::string out, directory;
|
|
const auto u16 = [](std::string& s, unsigned v) { s += char(v & 0xff); s += char(v >> 8 & 0xff); };
|
|
const auto u32 = [](std::string& s, unsigned long v) { for (int i = 0; i < 4; ++i) s += char(v >> (8 * i) & 0xff); };
|
|
for (const auto& [name, data] : files) {
|
|
const auto crc = mz_crc32(MZ_CRC32_INIT, reinterpret_cast<const unsigned char*>(data.data()), data.size());
|
|
const auto offset = out.size();
|
|
u32(out, 0x04034b50); u16(out, 20); u16(out, 0); u16(out, 0); u16(out, 0); u16(out, 0);
|
|
u32(out, crc); u32(out, static_cast<unsigned long>(data.size())); u32(out, static_cast<unsigned long>(data.size()));
|
|
u16(out, static_cast<unsigned>(name.size())); u16(out, 0);
|
|
out += name; out += data;
|
|
u32(directory, 0x02014b50); u16(directory, 20); u16(directory, 20); u16(directory, 0); u16(directory, 0);
|
|
u16(directory, 0); u16(directory, 0); u32(directory, crc);
|
|
u32(directory, static_cast<unsigned long>(data.size())); u32(directory, static_cast<unsigned long>(data.size()));
|
|
u16(directory, static_cast<unsigned>(name.size())); u16(directory, 0); u16(directory, 0); u16(directory, 0);
|
|
u16(directory, 0); u32(directory, name.ends_with('/') ? 0x10 : 0); u32(directory, static_cast<unsigned long>(offset));
|
|
directory += name;
|
|
}
|
|
const auto start = out.size();
|
|
out += directory;
|
|
u32(out, 0x06054b50); u16(out, 0); u16(out, 0);
|
|
u16(out, static_cast<unsigned>(files.size())); u16(out, static_cast<unsigned>(files.size()));
|
|
u32(out, static_cast<unsigned long>(directory.size())); u32(out, static_cast<unsigned long>(start)); u16(out, 0);
|
|
write(path, out);
|
|
}
|
|
|
|
std::string install(const fs::path& mods, const fs::path& source, bool replace = false) {
|
|
std::atomic<bool> cancel{};
|
|
return launcher_mods::install(mods, source, replace, {}, cancel);
|
|
}
|
|
|
|
template<class F> std::string error_of(F&& run) {
|
|
try { run(); } catch (const std::exception& failure) { return failure.what(); }
|
|
return {};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
wchar_t temp[MAX_PATH]{};
|
|
GetTempPathW(MAX_PATH, temp);
|
|
const auto root = fs::path(temp) / (L"reskate-mod-manager-tests-" + std::to_wstring(GetCurrentProcessId()));
|
|
fs::remove_all(root);
|
|
const auto game = root / L"game";
|
|
const auto sources = root / L"sources";
|
|
fs::create_directories(game);
|
|
const auto mods = launcher_mods::mods_root(game);
|
|
check(mods == game / L"Mods", "Without ModData the Mods folder sits beside the game");
|
|
|
|
// A mod inside a top-level folder, with its own description.
|
|
make_zip(sources / L"download (1).zip", {
|
|
{"Cool Park/", ""},
|
|
{"Cool Park/layout.toc", "toc"},
|
|
{"Cool Park/Win32/levels/cool.sb", "bundle"},
|
|
{"Cool Park/reskate-mod.json", R"({"schema":1,"name":"Cool Park Deluxe","author":"Zee","version":"1.2","description":"A park."})"},
|
|
{"Cool Park/reskate-levels.json", R"({"schema":1,"levels":[{"asset":"levels/game/cool/cool"}]})"}});
|
|
check(install(mods, sources / L"download (1).zip") == "Cool Park", "A zip's top folder names the mod");
|
|
check(read(mods / L"Cool Park" / L"Win32" / L"levels" / L"cool.sb") == "bundle", "Nested files are extracted");
|
|
check(!fs::exists(mods / L".reskate-install"), "Staging is cleaned up");
|
|
|
|
// Files at the top of the zip: the archive's name becomes the folder name.
|
|
make_zip(sources / L"Flat Mod!.zip", {{"reskate-levels.json", R"({"schema":1,"levels":[]})"}});
|
|
check(install(mods, sources / L"Flat Mod!.zip") == "Flat Mod_", "Unsafe characters in names are replaced");
|
|
|
|
// Mods packed deeper, as release zips often are.
|
|
make_zip(sources / L"pack.zip", {{"readme.txt", "hi"}, {"pack/Mods/Deep_Mod/layout.toc", "toc"}});
|
|
check(install(mods, sources / L"pack.zip") == "Deep_Mod", "The folder holding the marker is the mod");
|
|
check(!fs::exists(mods / L"Deep_Mod" / L"readme.txt"), "Files outside the mod are left behind");
|
|
|
|
// Same name again: refused, then replaced on request.
|
|
bool conflict = false;
|
|
try { install(mods, sources / L"download (1).zip"); } catch (const launcher_mods::AlreadyInstalled& existing) {
|
|
conflict = existing.name == "Cool Park";
|
|
}
|
|
check(conflict, "Installing over an installed mod asks first");
|
|
make_zip(sources / L"v2.zip", {{"Cool Park/layout.toc", "toc v2"}});
|
|
// A file of the installed copy held open: the replacement is refused and the copy stays whole.
|
|
{
|
|
const auto held = CreateFileW((mods / L"Cool Park" / L"layout.toc").c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr,
|
|
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
bool refused = false;
|
|
try { install(mods, sources / L"v2.zip", true); } catch (const std::exception&) { refused = true; }
|
|
CloseHandle(held);
|
|
check(refused && read(mods / L"Cool Park" / L"layout.toc") == "toc" && !fs::exists(mods / L".reskate-install"),
|
|
"A copy in use is not replaced, and stays as it was");
|
|
}
|
|
check(install(mods, sources / L"v2.zip", true) == "Cool Park" && read(mods / L"Cool Park" / L"layout.toc") == "toc v2",
|
|
"Replacing installs the new copy");
|
|
check(!fs::exists(mods / L".reskate-install"), "The replaced copy is deleted, not left beside the mods");
|
|
{
|
|
std::atomic<bool> cancel{};
|
|
unsigned callbacks{};
|
|
make_zip(sources / L"cancelled.zip", {{"Cool Park/layout.toc", "must not replace"}});
|
|
const auto issue = error_of([&] {
|
|
launcher_mods::install(mods, sources / L"cancelled.zip", true,
|
|
[&](float) { if (++callbacks == 2) cancel.store(true); }, cancel);
|
|
});
|
|
check(issue == "Install cancelled." && callbacks == 2,
|
|
"Cancellation at the final safe installation boundary was ignored");
|
|
check(read(mods / L"Cool Park" / L"layout.toc") == "toc v2" &&
|
|
!fs::exists(mods / L".reskate-install"),
|
|
"Cancelled replacement damaged the existing mod or left staging files");
|
|
write(sources / L"cancelled folder" / L"Cool Park" / L"layout.toc", "must not replace");
|
|
cancel.store(false);
|
|
callbacks = 0;
|
|
check(error_of([&] {
|
|
launcher_mods::install(mods, sources / L"cancelled folder", true,
|
|
[&](float) { if (++callbacks == 2) cancel.store(true); }, cancel);
|
|
}) == "Install cancelled." && read(mods / L"Cool Park" / L"layout.toc") == "toc v2",
|
|
"Cancelled folder replacement did not preserve the installed mod");
|
|
}
|
|
|
|
// Hostile or unrelated archives.
|
|
make_zip(sources / L"evil.zip", {{"../evil.txt", "x"}, {"layout.toc", "toc"}});
|
|
check(error_of([&] { install(mods, sources / L"evil.zip"); }).find("unsafe path") != std::string::npos &&
|
|
!fs::exists(game / L"evil.txt") && !fs::exists(mods / L"evil"),
|
|
"Paths leaving the mod folder are refused");
|
|
make_zip(sources / L"photos.zip", {{"a.jpg", "x"}});
|
|
check(error_of([&] { install(mods, sources / L"photos.zip"); }).find("does not contain a ReSkate mod") != std::string::npos,
|
|
"Archives without a mod are refused");
|
|
write(sources / L"notes.txt", "x");
|
|
check(!error_of([&] { install(mods, sources / L"notes.txt"); }).empty(), "Only .zip files and folders install");
|
|
|
|
// A folder.
|
|
write(sources / L"Loose" / L"My Folder Mod" / L"layout.toc", "toc");
|
|
check(install(mods, sources / L"Loose") == "My Folder Mod", "Folders install like zips");
|
|
check(fs::exists(sources / L"Loose" / L"My Folder Mod" / L"layout.toc"), "Installing a folder copies it");
|
|
|
|
// Links in a mod folder (issue #265): one inside it refuses the install and nothing outside is
|
|
// written; links on the way to the folder or to the Mods folder are fine. Junctions need no
|
|
// rights to make; a file symbolic link needs Developer Mode, so that case is skipped without it.
|
|
{
|
|
const auto place = root / L"links";
|
|
const auto junction = [](const fs::path& link, const fs::path& target) {
|
|
fs::create_directories(target);
|
|
return _wsystem((L"mklink /J \"" + link.wstring() + L"\" \"" + target.wstring() + L"\" >nul").c_str()) == 0;
|
|
};
|
|
const auto real_game = place / L"real game";
|
|
fs::create_directories(real_game);
|
|
const bool made = junction(place / L"linked game", real_game);
|
|
const auto linked_mods = launcher_mods::mods_root(place / L"linked game");
|
|
write(place / L"outside" / L"sentinel.txt", "untouched");
|
|
|
|
write(place / L"Junction Mod" / L"layout.toc", "toc");
|
|
const bool inner = junction(place / L"Junction Mod" / L"stuff", place / L"outside");
|
|
check(made && inner, "links: junctions could be made for the checks");
|
|
const auto refused = error_of([&] { install(linked_mods, place / L"Junction Mod"); });
|
|
check(refused.find("holds a link (stuff)") != std::string::npos && !fs::exists(linked_mods / L"Junction Mod") &&
|
|
read(place / L"outside" / L"sentinel.txt") == "untouched",
|
|
("A junction inside a mod folder refuses the install: " + refused).c_str());
|
|
check(!fs::exists(linked_mods / L".reskate-install"), "A refused folder leaves no staging behind");
|
|
|
|
write(place / L"Plain Mod" / L"layout.toc", "toc");
|
|
check(install(linked_mods, place / L"Plain Mod") == "Plain Mod" && read(real_game / L"Mods" / L"Plain Mod" / L"layout.toc") == "toc",
|
|
"A Mods folder reached through a junction still installs");
|
|
junction(place / L"picked", place / L"Picked Mod");
|
|
write(place / L"Picked Mod" / L"layout.toc", "toc");
|
|
check(install(linked_mods, place / L"picked") == "picked", "A mod folder picked through a junction still installs");
|
|
|
|
write(place / L"File Link Mod" / L"layout.toc", "toc");
|
|
const auto link = place / L"File Link Mod" / L"linked.txt";
|
|
if (CreateSymbolicLinkW(link.c_str(), (place / L"outside" / L"sentinel.txt").c_str(),
|
|
SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE)) {
|
|
const auto file_refused = error_of([&] { install(linked_mods, place / L"File Link Mod"); });
|
|
check(file_refused.find("holds a link (linked.txt)") != std::string::npos &&
|
|
read(place / L"outside" / L"sentinel.txt") == "untouched" && !fs::exists(linked_mods / L"File Link Mod"),
|
|
("A file link inside a mod folder refuses the install: " + file_refused).c_str());
|
|
} else {
|
|
std::cout << "SKIP: file symbolic link (Windows error " << GetLastError() << "; needs Developer Mode)\n";
|
|
}
|
|
// Junctions first, so nothing below follows one.
|
|
for (const auto* name : {L"linked game", L"picked", L"Junction Mod\\stuff"})
|
|
RemoveDirectoryW((place / name).c_str());
|
|
}
|
|
|
|
// The list: unlisted folders load after listed ones, by name.
|
|
auto list = mods::scan_mods(game);
|
|
check(list.present && list.issue.empty() && list.entries.size() == 4, "Every installed mod is listed");
|
|
check(list.entries[0].mod.name == "Cool Park" && list.entries[0].enabled, "Unlisted mods are enabled");
|
|
check(list.entries[0].mod.title == "Cool Park" && list.entries[0].mod.provides_layout,
|
|
"The replaced mod shows its new contents");
|
|
const auto deep = std::find_if(list.entries.begin(), list.entries.end(), [](const auto& e) { return e.mod.name == "Deep_Mod"; });
|
|
check(deep != list.entries.end() && deep->mod.title == "Deep_Mod", "Titles fall back to the folder name");
|
|
|
|
// Save an order with one mod disabled, then read it back.
|
|
std::reverse(list.entries.begin(), list.entries.end());
|
|
list.entries[1].enabled = false;
|
|
mods::save_mod_order(mods, list.entries);
|
|
const auto saved = mods::scan_mods(game);
|
|
check(saved.issue.empty() && saved.entries.size() == 4, "The saved mods.json reads back");
|
|
for (std::size_t i = 0; i < 4; ++i)
|
|
check(saved.entries[i].mod.name == list.entries[i].mod.name && saved.entries[i].enabled == list.entries[i].enabled,
|
|
"Order and enabled state round-trip");
|
|
check(read(mods / L"mods.json").find(R"("enabled": false)") != std::string::npos, "mods.json is plain readable JSON");
|
|
|
|
// Metadata from reskate-mod.json.
|
|
write(mods / L"Deep_Mod" / L"reskate-mod.json",
|
|
"\xef\xbb\xbf{\"name\":\"Deep\",\"author\":\"Someone\",\"version\":\"3\",\"description\":\"Line\\u0001\"}");
|
|
for (const auto& entry : mods::scan_mods(game).entries)
|
|
if (entry.mod.name == "Deep_Mod")
|
|
check(entry.mod.title == "Deep" && entry.mod.author == "Someone" && entry.mod.version == "3" &&
|
|
entry.mod.description == "Line?", "reskate-mod.json fills the details, control characters removed");
|
|
|
|
// manifest.json is preferred, with version_number.
|
|
write(mods / L"Deep_Mod" / L"manifest.json",
|
|
R"({"name":"Deep Manifest","author":"Park Maker","version_number":"2.1.0","description":"From the manifest"})");
|
|
for (const auto& entry : mods::scan_mods(game).entries)
|
|
if (entry.mod.name == "Deep_Mod")
|
|
check(entry.mod.title == "Deep Manifest" && entry.mod.author == "Park Maker" && entry.mod.version == "2.1.0" &&
|
|
entry.mod.description == "From the manifest", "manifest.json fills the details ahead of reskate-mod.json");
|
|
|
|
// Park mods: parks/<map>.park.json is listed per map.
|
|
write(mods / L"Deep_Mod" / L"parks" / L"bam.park.json", "{}");
|
|
write(mods / L"Deep_Mod" / L"parks" / L"grom.park.json", "{}");
|
|
for (const auto& entry : mods::scan_mods(game).entries)
|
|
if (entry.mod.name == "Deep_Mod")
|
|
check(entry.mod.park_maps == std::vector<std::string>{"bam", "grom"}, "Park maps are listed");
|
|
make_zip(sources / L"parkmod.zip", {{"My Park/manifest.json", R"({"name":"My Park"})"},
|
|
{"My Park/parks/bam.park.json", "{}"}});
|
|
check(install(mods, sources / L"parkmod.zip") == "My Park", "A park mod (manifest.json only) installs");
|
|
|
|
// Left-out mods: reported while their files are unchanged, forgotten once they change.
|
|
{
|
|
std::map<std::string, mods::Exclusion, std::less<>> exclusions;
|
|
exclusions["Deep_Mod"] = {mods::mod_fingerprint(mods / L"Deep_Mod"), "sdk", {"levels/game/x: damaged"}};
|
|
mods::write_exclusions(mods, exclusions);
|
|
const auto read_back = mods::read_exclusions(mods);
|
|
check(read_back.size() == 1 && read_back.at("Deep_Mod").sdk == "sdk" &&
|
|
read_back.at("Deep_Mod").problems == std::vector<std::string>{"levels/game/x: damaged"},
|
|
"Exclusions round-trip");
|
|
const auto listed = mods::scan_mods(game);
|
|
check(listed.excluded.contains("Deep_Mod") && listed.excluded.size() == 1, "An unchanged left-out mod is reported");
|
|
write(mods / L"Deep_Mod" / L"new file.txt", "reinstalled");
|
|
check(mods::scan_mods(game).excluded.empty(), "A changed mod is no longer reported as left out");
|
|
mods::write_exclusions(mods, {});
|
|
check(!fs::exists(mods / mods::exclusions_file), "An empty exclusion list removes the file");
|
|
}
|
|
|
|
// A broken mods.json still lists the folders, so the manager can repair it.
|
|
write(mods / L"mods.json", R"({"schema":1,"mods":[{"name":"Cool Park"}]})");
|
|
const auto broken = mods::scan_mods(game);
|
|
check(!broken.issue.empty() && broken.entries.size() == 5, "A malformed mods.json is reported, folders still listed");
|
|
mods::save_mod_order(mods, broken.entries);
|
|
check(mods::scan_mods(game).issue.empty(), "Saving repairs a malformed mods.json");
|
|
|
|
// A big collection, with the longest names, can be switched off and reordered, and the
|
|
// list reads back. (There was a limit of 64 mods once: past it nothing could be saved, so
|
|
// no mod could be disabled.)
|
|
{
|
|
constexpr std::size_t collection = 300;
|
|
const auto big_game = root / L"big";
|
|
const auto big = launcher_mods::mods_root(big_game);
|
|
for (std::size_t i = 0; i < collection; ++i) {
|
|
auto name = std::to_string(i);
|
|
name = std::string(4 - name.size(), '0') + name + "-";
|
|
name.resize(mods::maximum_mod_name, 'x');
|
|
write(big / name / L"layout.toc", "toc");
|
|
}
|
|
auto many = mods::scan_mods(big_game);
|
|
check(many.issue.empty() && many.entries.size() == collection, "A big Mods folder is listed");
|
|
for (std::size_t i = 0; i < many.entries.size(); i += 3) many.entries[i].enabled = false;
|
|
std::swap(many.entries.front(), many.entries.back());
|
|
std::string refusal;
|
|
try { mods::save_mod_order(big, many.entries); } catch (const std::exception& failure) { refusal = failure.what(); }
|
|
if (!refusal.empty()) std::cerr << "save refused: " << refusal << '\n';
|
|
check(refusal.empty(), "A big mod list can be saved");
|
|
const auto again = mods::scan_mods(big_game);
|
|
check(again.issue.empty() && again.entries.size() == many.entries.size(), "A big mods.json reads back");
|
|
bool same = again.entries.size() == many.entries.size();
|
|
for (std::size_t i = 0; same && i < again.entries.size(); ++i)
|
|
same = again.entries[i].mod.name == many.entries[i].mod.name && again.entries[i].enabled == many.entries[i].enabled;
|
|
check(same, "Every mod keeps its place and whether it loads");
|
|
}
|
|
|
|
// Thunderstore packages: manifest.json, icon.png and README.md at the top of the zip.
|
|
{
|
|
const auto store_game = root / L"store";
|
|
fs::create_directories(store_game);
|
|
const auto store = launcher_mods::mods_root(store_game);
|
|
const std::string manifest =
|
|
R"({"name":"Desert_Springs","version_number":"1.2.3","website_url":"","description":"Sand.","dependencies":[]})";
|
|
const auto installed = [&](std::string_view name) -> std::optional<mods::Mod> {
|
|
for (const auto& entry : mods::scan_mods(store_game).entries)
|
|
if (entry.mod.name == name) return entry.mod;
|
|
return std::nullopt;
|
|
};
|
|
|
|
// Downloaded from the site: files at the top, the archive named Namespace-Name-Version.
|
|
make_zip(sources / L"Zee-Desert_Springs-1.2.3.zip", {{"manifest.json", manifest}, {"icon.png", "png"},
|
|
{"README.md", "# Desert"}, {"layout.toc", "toc"}, {"Win32/levels/desert.sb", "bundle"}});
|
|
check(install(store, sources / L"Zee-Desert_Springs-1.2.3.zip") == "Zee-Desert_Springs",
|
|
"A downloaded package installs without the version in its folder name");
|
|
check(read(store / L"Zee-Desert_Springs" / L"icon.png") == "png" &&
|
|
read(store / L"Zee-Desert_Springs" / L"Win32" / L"levels" / L"desert.sb") == "bundle",
|
|
"Package files install with the mod");
|
|
make_zip(sources / L"Map-2.zip", {{"layout.toc", "toc"}});
|
|
check(install(store, sources / L"Map-2.zip") == "Map-2", "Names that merely end in a number keep it");
|
|
|
|
// The usual packaging mistake: the mod folder zipped inside the package.
|
|
make_zip(sources / L"nested.zip", {{"manifest.json", manifest}, {"icon.png", "png"}, {"README.md", "readme"},
|
|
{"CHANGELOG.md", "log"}, {"DesertSprings/layout.toc", "toc"},
|
|
{"DesertSprings/manifest.json", R"({"name":"Old"})"}, {"DesertSprings/Win32/levels/desert.sb", "bundle"}});
|
|
check(install(store, sources / L"nested.zip") == "DesertSprings",
|
|
"Content in a folder is found past the package's own manifest.json");
|
|
check(read(store / L"DesertSprings" / L"manifest.json") == manifest &&
|
|
read(store / L"DesertSprings" / L"icon.png") == "png" &&
|
|
read(store / L"DesertSprings" / L"CHANGELOG.md") == "log" &&
|
|
read(store / L"DesertSprings" / L"Win32" / L"levels" / L"desert.sb") == "bundle",
|
|
"Package files at the top follow the mod in and replace its own copies");
|
|
check(!fs::exists(store / L"DesertSprings" / L"DesertSprings"), "The wrapper folder is not kept");
|
|
|
|
// A package only replaces the nested metadata files it actually supplies.
|
|
const std::vector<std::pair<std::string, std::string>> partial_package{
|
|
{"manifest.json", manifest}, {"rEaDmE.md", "package readme"},
|
|
{"Partial/layout.toc", "toc"}, {"Partial/manifest.json", R"({"name":"Old"})"},
|
|
{"Partial/README.md", "old readme"}, {"Partial/icon.png", "nested icon"},
|
|
{"Partial/CHANGELOG.md", "nested changelog"}};
|
|
make_zip(sources / L"partial.zip", partial_package);
|
|
const auto folder_package = sources / L"partial-folder";
|
|
for (const auto& [name, data] : partial_package) write(folder_package / name, data);
|
|
for (const auto& source : {sources / L"partial.zip", folder_package}) {
|
|
check(install(store, source, true) == "Partial", "Partial packages install from zips and folders");
|
|
check(read(store / L"Partial" / L"manifest.json") == manifest &&
|
|
read(store / L"Partial" / L"README.md") == "package readme",
|
|
"Present package files replace nested copies regardless of filename case");
|
|
check(read(store / L"Partial" / L"icon.png") == "nested icon" &&
|
|
read(store / L"Partial" / L"CHANGELOG.md") == "nested changelog",
|
|
"Nested metadata survives when the package has no replacement");
|
|
}
|
|
|
|
// What the launcher's Thunderstore installs ask for.
|
|
launcher_mods::InstallOptions options;
|
|
options.folder = "Zee-Desert_Springs";
|
|
options.author = "Zee";
|
|
options.require_content = true;
|
|
std::atomic<bool> cancel{};
|
|
check(launcher_mods::install(store, sources / L"nested.zip", true, {}, cancel, options) == "Zee-Desert_Springs",
|
|
"The package's own folder name wins");
|
|
const auto desert = installed("Zee-Desert_Springs");
|
|
check(desert && desert->author == "Zee" && desert->version == "1.2.3" && desert->title == "Desert_Springs" &&
|
|
desert->provides_layout,
|
|
"The team becomes the author and the manifest gives the version");
|
|
|
|
make_zip(sources / L"authored.zip", {{"manifest.json", R"({"name":"Park","author":"Someone","version_number":"1.0.0"})"},
|
|
{"icon.png", "png"}, {"README.md", "readme"}, {"parks/bam.park.json", "{}"}});
|
|
options.folder = "Team-Park";
|
|
check(launcher_mods::install(store, sources / L"authored.zip", true, {}, cancel, options) == "Team-Park",
|
|
"A park package installs");
|
|
const auto park = installed("Team-Park");
|
|
check(park && park->author == "Someone" && park->park_maps == std::vector<std::string>{"bam"},
|
|
"An author the manifest already names is kept");
|
|
|
|
make_zip(sources / L"empty.zip", {{"manifest.json", manifest}, {"icon.png", "png"}, {"README.md", "readme"}});
|
|
options.folder = "Team-Empty";
|
|
check(error_of([&] { launcher_mods::install(store, sources / L"empty.zip", true, {}, cancel, options); })
|
|
.find("nothing for ReSkate to load") != std::string::npos &&
|
|
!fs::exists(store / L"Team-Empty"),
|
|
"Thunderstore installs refuse a package with nothing to load");
|
|
check(install(store, sources / L"empty.zip") == "empty", "A plain install still takes a description-only mod");
|
|
}
|
|
|
|
// A built-in pack in ReSkate's own format unpacks to exactly its files (a name with spaces
|
|
// included), and one with a path that leaves its folder unpacks nothing.
|
|
{
|
|
const auto pack = [](const std::vector<std::pair<std::string, std::string>>& files) {
|
|
std::string plain(mods::builtin_pack::magic.begin(), mods::builtin_pack::magic.end());
|
|
const auto put = [&](auto value) { plain.append(reinterpret_cast<const char*>(&value), sizeof(value)); };
|
|
put(static_cast<std::uint32_t>(files.size()));
|
|
for (const auto& [name, bytes] : files) {
|
|
put(static_cast<std::uint16_t>(name.size()));
|
|
plain += name;
|
|
put(static_cast<std::uint64_t>(bytes.size()));
|
|
}
|
|
for (const auto& file : files) plain += file.second;
|
|
std::vector<unsigned char> packed(ZSTD_compressBound(plain.size()));
|
|
packed.resize(ZSTD_compress(packed.data(), packed.size(), plain.data(), plain.size(), 19));
|
|
return packed;
|
|
};
|
|
const auto target = root / L"builtin" / L"good";
|
|
std::string issue;
|
|
const auto good = pack({{"layout.toc", "toc"}, {"sounds/body/Body Impact 1.wav", "RIFF"}, {"decks/wide/layout.toc", "deck"}});
|
|
check(mods::unpack_builtin_pack(good, target, issue) && issue.empty() && read(target / L"layout.toc") == "toc" &&
|
|
read(target / L"sounds" / L"body" / L"Body Impact 1.wav") == "RIFF" && read(target / L"decks" / L"wide" / L"layout.toc") == "deck",
|
|
"A built-in pack unpacks to its files");
|
|
const auto bad_target = root / L"builtin" / L"bad";
|
|
check(!mods::unpack_builtin_pack(pack({{"../escape.txt", "x"}}), bad_target, issue) && !issue.empty() &&
|
|
!fs::exists(root / L"builtin" / L"escape.txt"),
|
|
"A built-in pack may not write outside its folder");
|
|
auto cut = good;
|
|
cut.resize(cut.size() / 2);
|
|
check(!mods::unpack_builtin_pack(cut, root / L"builtin" / L"cut", issue), "A cut-short built-in pack is refused");
|
|
// The packs this build made (RESKATE_BUILTIN_PACKS: their folder), each unpacked whole.
|
|
std::wstring built(32768, L'\0');
|
|
built.resize(GetEnvironmentVariableW(L"RESKATE_BUILTIN_PACKS", built.data(), static_cast<DWORD>(built.size())));
|
|
if (!built.empty()) {
|
|
for (const auto& file : fs::directory_iterator(fs::path(built))) {
|
|
if (file.path().extension() != L".rskpack") continue;
|
|
std::ifstream in(file.path(), std::ios::binary);
|
|
const std::vector<unsigned char> bytes{std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
|
|
const auto out = root / L"builtin" / file.path().stem();
|
|
std::size_t count{};
|
|
std::uintmax_t size{};
|
|
const bool ok = mods::unpack_builtin_pack(bytes, out, issue);
|
|
for (const auto& entry : fs::recursive_directory_iterator(out))
|
|
if (entry.is_regular_file()) { ++count; size += entry.file_size(); }
|
|
std::cout << " " << file.path().filename().string() << ": " << (ok ? "unpacked " : "FAILED ") << count << " files, "
|
|
<< size << " bytes" << (ok ? "" : " (" + issue + ")") << '\n';
|
|
check(ok, "A built-in pack this build made unpacks");
|
|
}
|
|
}
|
|
}
|
|
|
|
// A retired mod (the deprecated first Board Break) is left out by ReSkate; every other mod is the player's.
|
|
{
|
|
const auto store = root / L"retired" / L"Mods";
|
|
fs::create_directories(store);
|
|
check(mods::kept_by_reskate(store, "fourth_studio-board_break") && mods::kept_by_reskate(store, "Fourth_Studio-Board_Break") &&
|
|
!mods::kept_by_reskate(store, "Fourth_Studio-Road_Rash") && !mods::kept_by_reskate(store, "Someone-Other_Mod") &&
|
|
!mods::kept_by_reskate(store, ""),
|
|
"Only a retired mod is left out");
|
|
}
|
|
|
|
std::error_code ignored;
|
|
fs::remove_all(root, ignored);
|
|
if (failures) {
|
|
std::cerr << failures << " mod manager check(s) failed\n";
|
|
return 1;
|
|
}
|
|
std::cout << "Mod manager checks passed.\n";
|
|
return 0;
|
|
}
|