Launcher: plugin mods show as Plugin instead of Nothing, and UPDATE sits after the version it brings

The mod list now notices a reskate-plugin.json that names an entry, reskate-objects.json and
reskate-music.json, and mods::mod_contents lists what a mod holds, most telling first. The
launcher's Type column shows the first of them (with "+" when there are more, all of them on
hover), and the in-game Mods page joins them. A reskate-plugin.json with only variants or
resident assets is not a plugin.

The UPDATE button moved from its own spot before the Type column to right after the blue
old -> new version, cutting a long name short rather than pushing into Type.
This commit is contained in:
AyeZeeBB committed 2026-10-24 11:24:20 -05:00
1 parent b739861fdf
commit e2d12a701d
5 files changed
+112 -33

No files matched your search

+31
View File
@@ -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<std::string>& 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<std::string> mod_contents(const Mod& mod) {
std::vector<std::string> 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);
+8
View File
@@ -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/<map>.park.json (park editor mods).
std::vector<std::string> 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<std::string> 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);
+8 -5
View File
@@ -12,6 +12,7 @@
#include <Windows.h>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <filesystem>
#include <memory>
#include <mutex>
@@ -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<char>(std::tolower(static_cast<unsigned char>(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;
}
+18
View File
@@ -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<std::string>{"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<std::string>{"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<std::string>{"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");
+47 -28
View File
@@ -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<char>(std::tolower(static_cast<unsigned char>(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();