Launcher: GET MODS installs plugin mods and mod packs instead of refusing them

A Thunderstore install was refused unless the package held layout.toc, reskate-levels.json or a
parks folder, so a package with only a plugin, or a pack whose manifest only lists dependencies,
could not be installed from GET MODS. reskate-plugin.json now counts as content (and finds the mod
inside a package like the other markers), and a manifest with dependencies is a mod pack, as
InstallOptions::require_content always said.
This commit is contained in:
AyeZeeBB committed 2026-10-23 16:35:42 -05:00
1 parent b978853bab
commit 7fa407ac36
3 files changed
+73 -20

No files matched your search

+39
View File
@@ -403,6 +403,45 @@ int main() {
!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");
make_zip(sources / L"empty-list.zip", {{"manifest.json", R"({"name":"Nothing","dependencies":[]})"}});
options.folder = "Team-Empty_List";
check(error_of([&] { launcher_mods::install(store, sources / L"empty-list.zip", true, {}, cancel, options); })
.find("plugins and mod packs") != std::string::npos &&
!fs::exists(store / L"Team-Empty_List"),
"An empty dependencies list is not a mod pack, and the refusal names plugins and packs");
// A plugin-only package: reskate-plugin.json is something to load, at the top or one folder down.
make_zip(sources / L"plugin.zip", {{"manifest.json", manifest}, {"icon.png", "png"}, {"README.md", "readme"},
{"reskate-plugin.json", R"({"api":1})"}, {"main.luau", "-- plugin"}});
options.folder = "Team-Plugin";
check(launcher_mods::install(store, sources / L"plugin.zip", true, {}, cancel, options) == "Team-Plugin" &&
read(store / L"Team-Plugin" / L"main.luau") == "-- plugin" &&
read(store / L"Team-Plugin" / L"icon.png") == "png",
"Thunderstore installs take a plugin-only package");
make_zip(sources / L"nested-plugin.zip", {{"manifest.json", manifest}, {"icon.png", "png"}, {"README.md", "readme"},
{"Plugin/reskate-plugin.json", R"({"api":1})"}, {"Plugin/main.luau", "-- nested"}});
options.folder = "Team-Nested_Plugin";
check(launcher_mods::install(store, sources / L"nested-plugin.zip", true, {}, cancel, options) == "Team-Nested_Plugin" &&
read(store / L"Team-Nested_Plugin" / L"reskate-plugin.json") == R"({"api":1})" &&
read(store / L"Team-Nested_Plugin" / L"main.luau") == "-- nested" &&
read(store / L"Team-Nested_Plugin" / L"icon.png") == "png" &&
read(store / L"Team-Nested_Plugin" / L"README.md") == "readme" &&
!fs::exists(store / L"Team-Nested_Plugin" / L"Plugin"),
"A plugin one folder down is the mod, and the package files follow it in");
const auto plugin_manifest = read(store / L"Team-Nested_Plugin" / L"manifest.json");
check(plugin_manifest.find("Desert_Springs") != std::string::npos && plugin_manifest.find(R"("Zee")") != std::string::npos,
"The carried manifest.json gets the team as author");
// A mod pack: nothing of its own to load, only dependencies the launcher installs first.
make_zip(sources / L"modpack.zip", {
{"manifest.json", R"({"name":"Pack","version_number":"1.0.0","dependencies":["Zee-Desert_Springs-1.2.3"]})"},
{"icon.png", "png"}, {"README.md", "readme"}});
options.folder = "Team-Pack";
check(launcher_mods::install(store, sources / L"modpack.zip", true, {}, cancel, options) == "Team-Pack" &&
read(store / L"Team-Pack" / L"icon.png") == "png",
"Thunderstore installs take a mod pack that only lists dependencies");
const auto pack = installed("Team-Pack");
check(pack && pack->author == "Zee" && pack->version == "1.0.0", "A mod pack is listed with its team as author");
}
// A built-in pack in ReSkate's own format unpacks to exactly its files (a name with spaces
+32 -18
View File
@@ -23,13 +23,15 @@ 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"};
// What the game loads from a mod (a plugin's reskate-plugin.json sits at its
// mod's top), and the files that only describe one.
constexpr std::array<std::string_view, 3> content_markers{"layout.toc", "reskate-levels.json", "reskate-plugin.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).";
constexpr char no_mod[] =
" does not contain a ReSkate mod (no layout.toc, reskate-levels.json, reskate-plugin.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";
@@ -84,8 +86,9 @@ struct Root {
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.
// The shallowest folder holding game content (layout.toc, reskate-levels.json,
// reskate-plugin.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) {
@@ -146,15 +149,11 @@ std::string mod_name(const Root& root, const std::string& source_name, const Ins
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;
// The mod's manifest.json as an object, if it has a readable one. An unreadable
// manifest is the mod's own business; the game reports it.
std::optional<Json> read_manifest(const fs::path& manifest) {
std::error_code error;
if (options.author.empty() || !fs::is_regular_file(manifest, error)) return;
if (!fs::is_regular_file(manifest, error)) return std::nullopt;
try {
std::string text;
{
@@ -163,13 +162,28 @@ void finish(const fs::path& staging, const Root& root, const std::string& source
}
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';
if (json.is_object()) return json;
} catch (...) {
// An unreadable manifest is the mod's own business; the game reports it.
}
return std::nullopt;
}
// Last steps in staging: a source that gives the game nothing to load is
// refused when asked, unless its manifest.json lists dependencies (a mod pack:
// what it brings is those), and the author goes into manifest.json.
void finish(const fs::path& staging, const Root& root, const std::string& source_name, const InstallOptions& options) {
const auto manifest = staging / mods::manifest_file;
auto json = read_manifest(manifest);
const bool pack = json && json->contains("dependencies") && json->at("dependencies").is_array() &&
!json->at("dependencies").empty();
if (options.require_content && !root.content && !pack)
fail(source_name + " has nothing for ReSkate to load: no layout.toc, reskate-levels.json, reskate-plugin.json "
"or parks folder, and no dependencies. Only mods built with ReSkate Studio, park mods, plugins and mod "
"packs can be installed.");
if (options.author.empty() || !json) 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';
}
// Entry names from an archive are untrusted: no absolute paths, drive
+2 -2
View File
@@ -40,8 +40,8 @@ struct InstallOptions {
};
// Installs `source` (a .zip, or a folder) as Mods/<name> and returns <name>.
// The mod is the shallowest folder holding layout.toc, reskate-levels.json or
// parks/<map>.park.json, else the shallowest holding manifest.json or
// The mod is the shallowest folder holding layout.toc, reskate-levels.json,
// reskate-plugin.json or parks/<map>.park.json, else the shallowest holding manifest.json or
// reskate-mod.json. Thunderstore's manifest.json, icon.png, README.md and
// CHANGELOG.md at the top of a package are copied in when the mod sits one
// folder deeper. A top-level mod takes the archive's name without a trailing