From 0706730e792b228e555ebf26e8196b32ffb32ee3 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Fri, 23 Oct 2026 15:22:30 +0200 Subject: [PATCH] Plugins: mods.content and read_content read the server's Mods on a dedicated server A server plugin with mods.read could call reskate.mods.content, but the server's game had no content of its own, so it got nil and "not available". A mode that keeps its data in mods (a race reading start and finish from a park mod) could only read it in the players' games. The server now answers from every mod folder in its Mods, by name, as it finds its maps there; it has no load order or mods.json. A hidden folder (.reskate_plugin_data) and names a plugin could not ask for are left out, and a mod's title is its manifest.json's "name". The listing and reading moved from plugin_game_runtime.cpp into plugin_content.cpp, which the game and the server share, so both keep the same limits: the folders the manifest's "content" names, 1000 files, four folders deep, 1 MB a file, nothing outside the mod's folder. The game's side is unchanged apart from calling it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SYabE7FCPgj3SaWeF92EML --- Extension/Plugins/Test/plugin_tests.cpp | 50 +++++++++++ Extension/Plugins/plugin_content.cpp | 104 ++++++++++++++++++++++ Extension/Plugins/plugin_content.h | 28 ++++++ Extension/Plugins/plugin_game_runtime.cpp | 59 ++---------- Server/Test/server_plugin_tests.cpp | 34 +++++++ Server/server_plugins.cpp | 21 +++++ Server/server_plugins.h | 3 +- cmake/Core.cmake | 1 + 8 files changed, 246 insertions(+), 54 deletions(-) create mode 100644 Extension/Plugins/plugin_content.cpp create mode 100644 Extension/Plugins/plugin_content.h diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index ebae3f1a..4d1059e7 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -1,5 +1,6 @@ // The plugin host apart from the game: manifests, the reskate API, storage, modules, hot reload, // and the sandbox against plugins written to break it (loops, memory, error floods, escapes). +#include "Extension/Plugins/plugin_content.h" #include "Extension/Plugins/plugin_discovery.h" #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" @@ -2893,6 +2894,54 @@ void a_plugin_reads_the_content_folder_it_names() { check(environment.printed("content ok") && game.called("content list parks"), "The plugin reads its content folder\n" + environment.all()); } +// What the game and a dedicated server read for mods.content (plugin_content.h), from real folders: +// every mod folder in a Mods folder by name, each one's files under the folder asked for, and the +// limits a plugin cannot get past. +void content_is_read_from_the_mod_folders() { + namespace content = dingosdk::plugins::content; + const auto mods = scratch / "content mods"; + const auto write = [](const std::filesystem::path& file, const std::string& text) { + std::filesystem::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary) << text; + }; + write(mods / "b_track" / "parks" / "start.json", "{}"); + write(mods / "b_track" / "parks" / "deep" / "a" / "b" / "c" / "level4.json", "{}"); + write(mods / "b_track" / "parks" / "deep" / "a" / "b" / "c" / "d" / "level5.json", "{}"); + write(mods / "b_track" / "parks" / "half.tmp", ""); + write(mods / "b_track" / "parks" / "big.json", std::string((1u << 20) + 1, ' ')); + write(mods / "b_track" / "manifest.json", R"({"name": "B Track", "version_number": "1.0.0"})"); + write(mods / "A_Park" / "parks" / "z.json", "zz"); + write(mods / "A_Park" / "reskate-levels.json", "{}"); + write(mods / "no parks" / "manifest.json", "not JSON"); + write(mods / ".reskate_plugin_data" / "parks" / "secret.json", "{}"); + write(mods / "loose.json", "{}"); + + const auto found = content::mod_folders(mods); + check(found.size() == 3 && found[0].name == "A_Park" && found[1].name == "b_track" && found[2].name == "no parks", + "Every mod folder, by name, none hidden"); + check(found[0].title == "A_Park" && found[1].title == "B Track" && found[2].title == "no parks", "titled as their manifest names them"); + check(content::mod_folders(scratch / "no such folder").empty(), "No Mods folder, no mods"); + + std::vector files; + content::list(found, "parks", files); + std::string listed; + for (const auto& file : files) listed += std::format("{}:{}:{} ", file.mod, file.path, file.size); + check(listed == std::format("A_Park:z.json:2 b_track:big.json:{} b_track:deep/a/b/c/level4.json:2 b_track:start.json:2 ", (1u << 20) + 1), + "Mod by mod, sorted, four folders deep at most, no .tmp: " + listed); + + std::string text; + check(content::read(found, "B_TRACK", "parks", "start.json", text).empty() && text == "{}", "A file is read, the mod named in any case"); + check(content::read(found, "b_track", "parks", "missing.json", text) == "not found", "A missing file"); + check(content::read(found, "b_track", "parks", "big.json", text) == "the file is larger than 1 MB", "A file over 1 MB"); + check(content::read(found, "nobody", "parks", "start.json", text) == "no such mod is on", "A mod that is not there"); + check(content::read(found, ".reskate_plugin_data", "parks", "secret.json", text) == "no such mod is on", "A hidden folder is no mod"); + + for (int i = 0; i < 1005; ++i) write(mods / "c_many" / "parks" / std::format("{:04}.json", i), ""); + files.clear(); + content::list(content::mod_folders(mods), "parks", files); + check(files.size() == content::most_files, std::format("At most {} files, {} listed", content::most_files, files.size())); +} + void board_break_snaps_the_board_on_a_slam() { TestEnvironment environment; std::filesystem::remove_all(scratch / "storage" / "board-break"); @@ -3896,6 +3945,7 @@ int main() { run(dual_stick_edits_the_companions_settings); run(rock_to_fakie_relabels_and_records_the_stall); run(a_plugin_reads_the_content_folder_it_names); + run(content_is_read_from_the_mod_folders); run(board_break_snaps_the_board_on_a_slam); run(fpv_drone_flies_and_follows); run(hall_of_meat_scores_a_bail); diff --git a/Extension/Plugins/plugin_content.cpp b/Extension/Plugins/plugin_content.cpp new file mode 100644 index 00000000..a2db86c5 --- /dev/null +++ b/Extension/Plugins/plugin_content.cpp @@ -0,0 +1,104 @@ +#include "plugin_content.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include + +namespace dingosdk::plugins::content { +namespace { +// The names mod_list.h's scan_mods takes for a mod folder, as long as read_content takes. +bool mod_name(std::string_view name) { + if (name.empty() || name.size() > 64 || name.front() == '.') return false; + return std::all_of(name.begin(), name.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == ' '; + }); +} +bool same_name(std::string_view a, std::string_view b) { + return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) { + const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast(c + 32) : c; }; + return lower(x) == lower(y); + }); +} +// manifest.json's "name", 1 to 64 characters, or empty when there is none to read. +std::string manifest_name(const std::filesystem::path& directory) { + const auto file = directory / "manifest.json"; + std::error_code error; + if (!std::filesystem::is_regular_file(file, error) || std::filesystem::file_size(file, error) > 32 * 1024 || error) return {}; + try { + std::ifstream in(file, std::ios::binary); + std::stringstream text; + text << in.rdbuf(); + const auto root = Json::parse(text.str()); + if (!root.is_object() || !root.contains("name") || !root.at("name").is_string()) return {}; + const auto& name = root.at("name").string(); + return name.size() <= 64 ? name : std::string{}; + } catch (const std::exception&) { + return {}; + } +} +} // namespace + +std::vector mod_folders(const std::filesystem::path& mods_root) { + std::vector mods; + std::error_code error; + for (std::filesystem::directory_iterator it(mods_root, error), end; !error && it != end; it.increment(error)) { + std::error_code entry; + if (!it->is_directory(entry)) continue; + const auto utf8 = it->path().filename().u8string(); + std::string name(utf8.begin(), utf8.end()); + if (!mod_name(name)) continue; + auto title = manifest_name(it->path()); + mods.push_back({name, title.empty() ? name : std::move(title), it->path()}); + } + std::sort(mods.begin(), mods.end(), [](const Mod& a, const Mod& b) { return a.name < b.name; }); + return mods; +} + +void list(const std::vector& mods, std::string_view folder, std::vector& files) { + for (const auto& mod : mods) { + const auto root = mod.directory / std::filesystem::path(std::string(folder)); + std::error_code error; + if (!std::filesystem::is_directory(root, error)) continue; + std::vector found; + for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end; + !error && it != end; it.increment(error)) { + if (it.depth() >= 4) it.disable_recursion_pending(); + if (!it->is_regular_file(error)) continue; + const auto relative = it->path().lexically_relative(root).generic_wstring(); + std::string path; + bool plain = true; + for (const auto c : relative) { + if (c < 0x20 || c > 0x7e) plain = false; + path.push_back(static_cast(c)); + } + if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again + found.push_back({mod.name, mod.title, std::move(path), static_cast(it->file_size(error))}); + if (files.size() + found.size() >= most_files) break; + } + std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; }); + for (auto& file : found) files.push_back(std::move(file)); + if (files.size() >= most_files) break; + } +} + +std::string read(const std::vector& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text) { + const auto found = std::find_if(mods.begin(), mods.end(), [&](const Mod& each) { return same_name(each.name, mod); }); + if (found == mods.end()) return "no such mod is on"; + const auto root = found->directory / std::filesystem::path(std::string(folder)); + const auto file = root / std::filesystem::path(std::string(path)); + std::error_code error; + const auto real_root = std::filesystem::weakly_canonical(root, error); + const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error); + if (error) return "not found"; + const auto relative = real_file.lexically_relative(real_root); + if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder"; + if (!std::filesystem::is_regular_file(file, error)) return "not found"; + const auto size = std::filesystem::file_size(file, error); + if (error) return "the file cannot be read"; + if (size > most_bytes) return "the file is larger than 1 MB"; + std::ifstream in(file, std::ios::binary); + text.resize(static_cast(size)); + if (!in || !in.read(text.data(), static_cast(size))) return "the file cannot be read"; + return {}; +} +} // namespace dingosdk::plugins::content diff --git a/Extension/Plugins/plugin_content.h b/Extension/Plugins/plugin_content.h new file mode 100644 index 00000000..7ead29e9 --- /dev/null +++ b/Extension/Plugins/plugin_content.h @@ -0,0 +1,28 @@ +#pragma once +#include "plugin_game.h" +#include +#include +#include +#include +#include + +// mods.content and read_content, from the mods' folders on disk: the game reads its enabled mods +// (plugin_game_runtime.cpp), a dedicated server every mod folder in its Mods (server_plugins.cpp). +// Both keep the same limits, so a plugin reads the same files on either. +namespace dingosdk::plugins::content { +struct Mod { + std::string name, title; // the folder's name and the mod's own name + std::filesystem::path directory; +}; +inline constexpr std::size_t most_files = 1000, most_bytes = 1u << 20; + +// Every folder in `mods_root` a mod can be, by name: not hidden (".reskate_plugin_data"), and a +// name a plugin can ask for again. Titled by its manifest.json's "name", else its folder's name. +std::vector mod_folders(const std::filesystem::path& mods_root); +// The files under // of each of `mods` in turn, sorted within a mod, four folders deep +// at most and most_files in all; `folder` is checked against the manifest already. +void list(const std::vector& mods, std::string_view folder, std::vector& files); +// One of them, at most most_bytes; `path` is checked already (safe_relative_path). Empty when read, +// else why not. +std::string read(const std::vector& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text); +} // namespace dingosdk::plugins::content diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 70409d7c..ecc370b6 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -1,5 +1,6 @@ #include "plugin_game_runtime.h" #include "plugin_session_feed.h" +#include "plugin_content.h" #include "Runtime/runtime_internal.h" #include "plugin_ui.h" #include "Engine/Game/World/park_rotation.h" @@ -1442,13 +1443,9 @@ public: tricks::prepare_trick_gestures(); return tricks::trick_gesture(trick); } - // ---- content: // of every enabled mod --------------------------------------------- + // ---- content: // of every enabled mod (plugin_content.h) -------------------------- // The enabled mods, read again at most every 2 s: a page may list them every frame. - struct EnabledMod { - std::string name, title; - std::filesystem::path directory; - }; - std::vector enabled_mods() { + std::vector enabled_mods() { const auto now = GetTickCount64(); if (enabled_read_at_ && now - enabled_read_at_ < 2000) return enabled_mods_; enabled_read_at_ = now; @@ -1459,56 +1456,12 @@ public: if (entry.enabled) enabled_mods_.push_back({entry.mod.name, entry.mod.title.empty() ? entry.mod.name : entry.mod.title, entry.mod.directory}); return enabled_mods_; } - static constexpr std::size_t most_content_files = 1000, most_content_bytes = 1u << 20; std::string content_list(std::string_view folder, std::vector& files) override { - for (const auto& mod : enabled_mods()) { - const auto root = mod.directory / std::filesystem::path(std::string(folder)); - std::error_code error; - if (!std::filesystem::is_directory(root, error)) continue; - std::vector found; - for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end; - !error && it != end; it.increment(error)) { - if (it.depth() >= 4) it.disable_recursion_pending(); - if (!it->is_regular_file(error)) continue; - const auto relative = it->path().lexically_relative(root).generic_wstring(); - std::string path; - bool plain = true; - for (const auto c : relative) { - if (c < 0x20 || c > 0x7e) plain = false; - path.push_back(static_cast(c)); - } - if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again - found.push_back({mod.name, mod.title, std::move(path), static_cast(it->file_size(error))}); - if (files.size() + found.size() >= most_content_files) break; - } - std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; }); - for (auto& file : found) files.push_back(std::move(file)); - if (files.size() >= most_content_files) break; - } + content::list(enabled_mods(), folder, files); return {}; } std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string& text) override { - const auto mods_now = enabled_mods(); - const auto found = std::find_if(mods_now.begin(), mods_now.end(), [&](const EnabledMod& each) { - return each.name.size() == mod.size() && _strnicmp(each.name.c_str(), mod.data(), mod.size()) == 0; - }); - if (found == mods_now.end()) return "no such mod is on"; - const auto root = found->directory / std::filesystem::path(std::string(folder)); - const auto file = root / std::filesystem::path(std::string(path)); - std::error_code error; - const auto real_root = std::filesystem::weakly_canonical(root, error); - const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error); - if (error) return "not found"; - const auto relative = real_file.lexically_relative(real_root); - if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder"; - if (!std::filesystem::is_regular_file(file, error)) return "not found"; - const auto size = std::filesystem::file_size(file, error); - if (error) return "the file cannot be read"; - if (size > most_content_bytes) return "the file is larger than 1 MB"; - std::ifstream in(file, std::ios::binary); - text.resize(static_cast(size)); - if (!in || !in.read(text.data(), static_cast(size))) return "the file cannot be read"; - return {}; + return content::read(enabled_mods(), mod, folder, path, text); } std::string trick_label(std::string_view plugin, std::string_view from, std::string_view to) override { return trick_rewrite::label(plugin, from, to); @@ -1741,7 +1694,7 @@ private: std::vector installed; }; std::map> variant_reads_; - std::vector enabled_mods_; + std::vector enabled_mods_; std::string reacher_; // the plugin holding the skater's arm (skater_reach) std::string poser_; // the plugin turning the skater's joints (skater_pose) std::string carrier_; // the plugin having the skater carry something (skater_carry) diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index 4a682735..6999f1c7 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -211,6 +211,10 @@ int run(const std::filesystem::path &fixtures) { local ok, why = pcall(reskate.server.parks) reskate.console.print(ok and "blind listed parks" or "blind parks refused: " .. tostring(why)) end) + reskate.console.command("blind content", "mod content without mods.read", function() + local ok, why = pcall(reskate.mods.content, "parks") + reskate.console.print(ok and "blind listed content" or "blind content refused: " .. tostring(why)) + end) )"); // One that says what the level is, and when it changes. @@ -251,6 +255,22 @@ int run(const std::filesystem::path &fixtures) { end) )"); + // One that reads the parks folder of the mods the server has, as a game's plugin reads its + // enabled mods'. + write(root / "Plugins" / "reader" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "mods.read"], "content": ["parks"]})"); + write(root / "Plugins" / "reader" / "plugin" / "main.lua", R"( + reskate.console.command("reader", "mod content, for the test", function() + for _, f in ipairs(assert(reskate.mods.content("parks"))) do + reskate.console.print(string.format("content %s (%s) %s %d", f.mod, f.title, f.path, f.size)) + end + local text = assert(reskate.mods.read_content("mixed park", "parks", "grom.park.json")) + reskate.console.print("read " .. #text .. " " .. tostring(text:find('"reskate-park"', 1, true) ~= nil)) + local missing, why = reskate.mods.read_content("mixed park", "parks", "nothing.json") + reskate.console.print("missing " .. tostring(missing) .. " " .. why) + end) + )"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -350,6 +370,20 @@ int run(const std::filesystem::path &fixtures) { plugin_command("blind parks"); check(has(log, "blind parks refused: ") && has(log, "server.parks"), "A plugin without server.parks listed the parks"); + // The mods the server has, read through mods.content as a game reads its enabled mods. + const auto size_of = [&](const char *mod, const char *file) { + return std::to_string(std::filesystem::file_size(root / "Mods" / mod / "parks" / file)); + }; + plugin_command("reader"); + check(has(log, "content mixed park (mixed park) grom.park.json " + size_of("mixed park", "grom.park.json")) && + has(log, "content popular skate 2 street park (popular skate 2 street park) bam.park.json " + + size_of("popular skate 2 street park", "bam.park.json")), + "The server's mods' parks folders were not listed"); + check(has(log, "read " + size_of("mixed park", "grom.park.json") + " true"), "A park mod's file was not read"); + check(has(log, "missing nil not found"), "A missing file was not \"not found\""); + plugin_command("blind content"); + check(has(log, "blind content refused: ") && has(log, "mods.read"), "A plugin without mods.read listed the mods' content"); + // The level, as players' games name it, and its change heard when the server changes it. const auto *first = find_level(config.map); const auto other = std::find_if(levels().begin(), levels().end(), [&](const auto &l) { return l.asset != first->asset; }); diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index 5eeb453e..a0c33501 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -1,5 +1,6 @@ #include "server_plugins.h" #include "server_host.h" +#include "Extension/Plugins/plugin_content.h" #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" #include "Extension/Plugins/plugin_wire.h" @@ -101,6 +102,15 @@ class ServerGame final : public Game { } return result; } + // mods.content: every mod folder in the server's Mods, as a game reads its enabled mods. The + // server has no load order or mods.json of its own; the folders come by name, as for its maps. + std::string content_list(std::string_view folder, std::vector &files) override { + content::list(mod_folders(), folder, files); + return {}; + } + std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string &text) override { + return content::read(mod_folders(), mod, folder, path, text); + } // Through the console's own ban and unban, so they keep its checks and save the bans file. std::string ban(std::uint64_t player) override { const auto answer = host_.command("ban " + std::to_string(player)); @@ -116,6 +126,15 @@ class ServerGame final : public Game { } private: + // Read again at most every 2 s: a plugin may list them every tick. + const std::vector &mod_folders() { + const auto now = std::chrono::steady_clock::now(); + if (!mods_read_ || now - *mods_read_ >= std::chrono::seconds(2)) { + mods_read_ = now; + mods_ = content::mod_folders(host_.config().mods); + } + return mods_; + } bool present(std::uint64_t player) const { for (const auto &p : host_.player_list()) if (p.id == player) return true; @@ -123,6 +142,8 @@ class ServerGame final : public Game { } Host &host_; ServerPlugins::Log log_; + std::vector mods_; + std::optional mods_read_; }; class ServerEnvironment final : public Environment { diff --git a/Server/server_plugins.h b/Server/server_plugins.h index 402dfe56..144a3d06 100644 --- a/Server/server_plugins.h +++ b/Server/server_plugins.h @@ -12,7 +12,8 @@ // a game mode's rules run. The operator installs them in Plugins beside the server, a folder a // plugin (with its reskate-plugin.json, "side" "server" or "both"); they load, reload and unload as // the folder changes, checked every 2 s. The server has no game: what a plugin may do here is the -// session's messages, its players, the server API (kick, say, tell, move) and its storage. +// session's messages, its players, the server API (kick, say, tell, move), its storage and reading +// the content folders of the mods in Mods (mods.content). // Everything here runs on the server's tick thread. namespace dingosdk::server { class Host; diff --git a/cmake/Core.cmake b/cmake/Core.cmake index 9e928e3b..d4507b7d 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -147,6 +147,7 @@ add_library(dingosdk_plugins STATIC Extension/Plugins/plugin_api_services.cpp Extension/Plugins/plugin_api_throwdowns.cpp Extension/Plugins/plugin_api_capture.cpp + Extension/Plugins/plugin_content.cpp # The throwdown kit's store and the plugins' modes, which the plugin host and the game's # native UI share (kit::flag is the game's: Extension/Throwdowns/native_flag_session.cpp). Extension/Throwdowns/throwdown_kit.cpp