From 7fa407ac36490cd33503e73101a2eb981b5433bd Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 23 Oct 2026 16:35:42 -0500 Subject: [PATCH] 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. --- Launcher/Test/mod_manager_tests.cpp | 39 ++++++++++++++++++++++ Launcher/mod_manager.cpp | 50 ++++++++++++++++++----------- Launcher/mod_manager.h | 4 +-- 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index 9d7b1013..05a18320 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -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 diff --git a/Launcher/mod_manager.cpp b/Launcher/mod_manager.cpp index 44ee9845..b02e5637 100644 --- a/Launcher/mod_manager.cpp +++ b/Launcher/mod_manager.cpp @@ -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 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 content_markers{"layout.toc", "reskate-levels.json", "reskate-plugin.json"}; constexpr std::array 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 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/.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/.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 mod_root(const std::vector& 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 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 diff --git a/Launcher/mod_manager.h b/Launcher/mod_manager.h index 5f9f69ae..b1e22d8d 100644 --- a/Launcher/mod_manager.h +++ b/Launcher/mod_manager.h @@ -40,8 +40,8 @@ struct InstallOptions { }; // Installs `source` (a .zip, or a folder) as Mods/ and returns . -// The mod is the shallowest folder holding layout.toc, reskate-levels.json or -// parks/.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/.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