diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index fed3442b..1c103ed9 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -22,6 +22,8 @@ namespace fs = std::filesystem; constexpr char layout_name[] = "layout.toc"; constexpr char levels_name[] = "reskate-levels.json"; +constexpr char objects_name[] = "reskate-objects.json"; // content_catalogs.h's mod_objects_file +constexpr char music_name[] = "reskate-music.json"; // mod_music.h // Written by the tool that built the mod (Studio, since 2026-09-24): who made // it and when. Purely informational, so it is read loosely and never rejected. constexpr char build_name[] = "reskate-build.json"; @@ -230,6 +232,19 @@ void read_variants(Mod& mod, std::vector& notes) { } } +// Whether reskate-plugin.json names code to run. A file with only "resident" or "variants" serves +// the mod's game data and is no plugin. The plugin host reports what else is wrong with it. +void read_plugin_entry(Mod& mod) { + const auto path = mod.directory / plugin_manifest_file; + std::error_code error; + if (!fs::is_regular_file(path, error) || error) return; + try { + const auto root = parse(path, maximum_plugin_bytes); + mod.provides_plugin = root.is_object() && root.contains("entry") && root.at("entry").is_string(); + } catch (...) { + } +} + // .reskate-studio-patch: the stamp line, then key=value lines. A mod that ships game data must // record the Skate.exe it was built for, and it must be this one (supported_build). void read_studio_marker(Mod& mod) { @@ -469,6 +484,8 @@ Mod describe_mod(const std::filesystem::path& directory, std::string name, std:: mod.directory = directory; mod.provides_layout = fs::is_regular_file(mod.directory / layout_name, error); mod.provides_levels = fs::is_regular_file(mod.directory / levels_name, error); + mod.provides_objects = fs::is_regular_file(mod.directory / objects_name, error); + mod.provides_music = fs::is_regular_file(mod.directory / music_name, error); for (fs::directory_iterator it(mod.directory / "parks", error), end; !error && it != end; it.increment(error)) { auto file = ascii_path(it->path().filename()); if (!file.ends_with(".park.json") || mod.park_maps.size() >= 16) continue; @@ -481,10 +498,24 @@ Mod describe_mod(const std::filesystem::path& directory, std::string name, std:: read_levels(mod); read_resident(mod, notes); read_variants(mod, notes); + read_plugin_entry(mod); read_studio_marker(mod); return mod; } +std::vector mod_contents(const Mod& mod) { + std::vector parts; + if (mod.provides_levels) + parts.push_back(mod.levels.size() > 1 ? std::to_string(mod.levels.size()) + " maps" : "Map"); + if (!mod.park_maps.empty()) parts.push_back(mod.park_maps.size() == 1 ? "Park" : "Parks"); + if (mod.provides_plugin) parts.push_back("Plugin"); + // A map's own game data is part of the map. + if (mod.provides_layout && !mod.provides_levels) parts.push_back("Game data"); + if (mod.provides_objects) parts.push_back("Objects"); + if (mod.provides_music) parts.push_back("Music"); + return parts; +} + bool resident_root(const Mod& mod, std::string_view bundle, std::string_view name) noexcept { try { const auto b = lower(bundle), n = lower(name); diff --git a/Engine/Vfs/mod_list.h b/Engine/Vfs/mod_list.h index d8c40459..449e02bd 100644 --- a/Engine/Vfs/mod_list.h +++ b/Engine/Vfs/mod_list.h @@ -76,6 +76,9 @@ struct Mod { std::filesystem::path directory; bool provides_layout{}; // has layout.toc, so it becomes a layout layer bool provides_levels{}; // has reskate-levels.json + bool provides_plugin{}; // its reskate-plugin.json names Luau code to run (an "entry") + bool provides_objects{}; // has reskate-objects.json (Quick Drop object names and families) + bool provides_music{}; // has reskate-music.json (playlists for its songs) // Base maps it ships a park for: parks/.park.json (park editor mods). std::vector park_maps; // Presentation, from manifest.json (or reskate-mod.json). `title` falls back to the folder @@ -154,6 +157,11 @@ struct ModList { bool resident_root(const Mod& mod, std::string_view bundle, std::string_view name) noexcept; const ResidentAssets::Shader* resident_shader(const Mod& mod, std::string_view bundle, std::string_view name) noexcept; +// What a mod holds, for a player, what says most about it first: "Map" or "3 maps", "Park" or +// "Parks", "Plugin", "Game data", "Objects", "Music". Empty when it holds none of these. A plugin +// that also ships game data reads as a plugin first, since its code is what it is for. +std::vector mod_contents(const Mod& mod); + // Folder names travel into engine paths, so only plain ASCII is accepted: // letters, digits, space, '_', '-' and '.', not starting with '.'. bool valid_mod_name(std::string_view name); diff --git a/Extension/UI/Overlay/modding_menu.cpp b/Extension/UI/Overlay/modding_menu.cpp index 78a9055e..1b5fd0bd 100644 --- a/Extension/UI/Overlay/modding_menu.cpp +++ b/Extension/UI/Overlay/modding_menu.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -69,10 +70,12 @@ std::filesystem::path game_directory() { return std::filesystem::path(std::wstring(executable.data(), length)).parent_path(); } // What a mod brings, as the native Mods page words it. -std::string mod_contents(const mods::Mod& mod) { - std::string text = mod.provides_layout && mod.provides_levels ? "Maps + game data" - : mod.provides_levels ? "Maps" : mod.provides_layout ? "Game data" : ""; - if (!mod.park_maps.empty()) text += text.empty() ? "Parks" : " + parks"; +std::string contents_text(const mods::Mod& mod) { + std::string text; + for (auto part : mods::mod_contents(mod)) { + if (!text.empty()) part[0] = static_cast(std::tolower(static_cast(part[0]))); + text += (text.empty() ? "" : " + ") + part; + } return text.empty() ? "Nothing to load" : text; } // Every mod folder and what mods.json says about it, re-read every 1.5 s while @@ -250,7 +253,7 @@ void draw_installed_mods(SkateMenu& menu) { status = "APPLY TO UNLOAD"; colour = skate_theme::danger; } - if (tile(index++, mod.title.empty() ? mod.name : mod.title, byline(mod), status, colour, mod_contents(mod), hover, + if (tile(index++, mod.title.empty() ? mod.name : mod.title, byline(mod), status, colour, contents_text(mod), hover, entry.enabled ? "Disable" : "Enable")) toggle = i; } diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index 05a18320..48f3146d 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -261,6 +261,24 @@ int main() { for (const auto& entry : mods::scan_mods(game).entries) if (entry.mod.name == "Deep_Mod") check(entry.mod.park_maps == std::vector{"bam", "grom"}, "Park maps are listed"); + + // What a mod holds, for the Type column: a plugin is one only when its manifest names code. + write(mods / L"Deep_Mod" / L"reskate-plugin.json", R"({"variants":{"folder":"decks"}})"); + for (const auto& entry : mods::scan_mods(game).entries) + if (entry.mod.name == "Deep_Mod") + check(!entry.mod.provides_plugin && mods::mod_contents(entry.mod) == std::vector{"Parks", "Game data"}, + "A reskate-plugin.json without an entry is no plugin"); + write(mods / L"Deep_Mod" / L"reskate-plugin.json", R"({"entry":"plugin/main.lua","permissions":[]})"); + write(mods / L"Deep_Mod" / L"reskate-music.json", "{}"); + write(mods / L"Deep_Mod" / L"reskate-objects.json", "{}"); + for (const auto& entry : mods::scan_mods(game).entries) + if (entry.mod.name == "Deep_Mod") + check(entry.mod.provides_plugin && + mods::mod_contents(entry.mod) == std::vector{"Parks", "Plugin", "Game data", "Objects", "Music"}, + "Plugins, objects and music are listed with what else the mod holds"); + std::filesystem::remove(mods / L"Deep_Mod" / L"reskate-plugin.json"); + std::filesystem::remove(mods / L"Deep_Mod" / L"reskate-music.json"); + std::filesystem::remove(mods / L"Deep_Mod" / L"reskate-objects.json"); 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"); diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 9dcf3908..10e75657 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -391,12 +391,20 @@ std::string pair_reason(const ConflictPair& pair) { std::format(" ({} {})", pair.row->assets, pair.row->assets == 1 ? "file" : "files"); } -// What a mod brings, for the list's Type column. -std::string kind_of(const mods::Mod& mod) { - if (!mod.levels.empty()) return mod.levels.size() == 1 ? "Map" : std::format("{} maps", mod.levels.size()); - if (!mod.park_maps.empty()) return "Park"; - if (mod.provides_layout) return "Game data"; - return "Nothing"; +// What a mod brings, for the list's Type column: what says most about it, and on hover all of it. +struct Kind { + std::string label, tip; +}; +Kind kind_of(const mods::Mod& mod) { + const auto parts = mods::mod_contents(mod); + if (parts.empty()) return {"Nothing", "Holds nothing ReSkate loads"}; + std::string all; + for (std::size_t at = 0; at < parts.size(); ++at) { + auto part = parts[at]; + if (at) part[0] = static_cast(std::tolower(static_cast(part[0]))); + all += (at == 0 ? "" : at + 1 == parts.size() ? " and " : ", ") + part; + } + return {parts.size() == 1 ? parts.front() : parts.front() + " +", "Holds: " + all}; } // The rows the list shows, as places in the load order: what the search and the @@ -632,7 +640,7 @@ struct Columns { }; // Where the right-hand columns of a row `width` wide start, from its left edge. struct RightColumns { - float more, status, type, update; + float more, status, type; }; RightColumns right_columns(float width) { const Columns c; @@ -640,7 +648,6 @@ RightColumns right_columns(float width) { r.more = width - S(10) - c.more; r.status = r.more - S(12) - c.status; r.type = r.status - S(12) - c.type; - r.update = r.type - S(10) - c.update; return r; } @@ -767,23 +774,17 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins // The same icon the store shows, so a mod looks like itself on both pages. mod_icon(panel, package, ImVec2(start.x + c.icon, middle - S(15)), S(30)); - // Right to left: the menu, how it stands, what it is, and an update waiting for it. + // Right to left: the menu, how it stands, and what it is. ImGui::SetCursorScreenPos(ImVec2(start.x + r.more, middle - c.more * 0.5f)); if (more_button("##more", c.more)) menu = true; if (ImGui::IsItemHovered()) ImGui::SetTooltip("More"); - float name_end = start.x + r.type - S(12); - if (update) { - ImGui::SetCursorScreenPos(ImVec2(start.x + r.update, middle - S(14))); - push_primary_button(); - if (ImGui::Button("UPDATE", ImVec2(c.update, S(28)))) request = {Request::Kind::update, {mod.name}}; - pop_primary_button(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Thunderstore has v%s", package->latest().number.c_str()); - name_end = start.x + r.update - S(12); - } const auto kind = kind_of(mod); draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(start.x + r.type, middle - fonts.body->FontSize * 0.5f), - color::muted, kind.c_str()); + color::muted, kind.label.c_str()); + if (ImGui::IsWindowHovered() && + ImGui::IsMouseHoveringRect(ImVec2(start.x + r.type, start.y), ImVec2(start.x + r.status, start.y + tall))) + ImGui::SetTooltip("%s", kind.tip.c_str()); // How it stands: a pill, and on hover why. { @@ -817,24 +818,42 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins ImGui::SetTooltip("%s", tip.c_str()); } - // The name, who made it, and its version: with an update waiting, old -> new in blue. + // The name, who made it, and its version: with an update waiting, old -> new in blue and the + // UPDATE button right after it, so the button sits with the change it makes. A name too long + // for the row is cut short before the button rather than pushing it into the Type column. { - const float x = start.x + c.name; + const float x = start.x + c.name, limit = start.x + r.type - S(12); + const auto to = update ? (mod.version.empty() ? std::string() : "v" + mod.version + " ") + "\xe2\x86\x92 v" + + package->latest().number + : std::string(); + const auto by = mod.author.empty() ? std::string() + : "by " + mod.author + (mod.version.empty() || update ? std::string() : " v" + mod.version); + const auto wide = [&](ImFont* font, const std::string& text) { + return font->CalcTextSizeA(font->FontSize, FLT_MAX, 0, text.c_str()).x; + }; + float name_end = limit; + if (update) { + const float text = wide(fonts.bold, mod.title) + S(10) + (by.empty() ? 0.0f : wide(fonts.body, by) + S(10)) + + wide(fonts.body, to); + const float button_x = std::min(x + text + S(12), limit - c.update); + ImGui::SetCursorScreenPos(ImVec2(button_x, middle - S(13))); + push_primary_button(); + if (ImGui::Button("UPDATE", ImVec2(c.update, S(26)))) request = {Request::Kind::update, {mod.name}}; + pop_primary_button(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Thunderstore has v%s", package->latest().number.c_str()); + name_end = button_x - S(12); + } const ImVec4 clip(x, start.y, name_end, start.y + tall); const float y = middle - fonts.bold->FontSize * 0.5f, body_y = middle - fonts.body->FontSize * 0.5f; draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(x, y), entry.enabled ? color::text : color::muted, mod.title.c_str(), nullptr, 0, &clip); float after = x + fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, 0, mod.title.c_str()).x + S(10); - if (!mod.author.empty()) { - const auto by = "by " + mod.author + (mod.version.empty() || update ? std::string() : " v" + mod.version); + if (!by.empty()) { draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(after, body_y), color::muted, by.c_str(), nullptr, 0, &clip); - after += fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, 0, by.c_str()).x + S(10); + after += wide(fonts.body, by) + S(10); } - if (update) { - const auto to = (mod.version.empty() ? std::string() : "v" + mod.version + " ") + "\xe2\x86\x92 v" + - package->latest().number; + if (update) draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(after, body_y), color::blue, to.c_str(), nullptr, 0, &clip); - } } end_row();